Skip to content

feat(#1956): port the opencode gate adapter to the V2 plugin API - #1959

Merged
atlas-apex merged 14 commits into
me2resh:devfrom
ahmed-bermawy:feature/1956-opencode-v2-plugin-adapter
Nov 5, 2026
Merged

atlas-apex merged 14 commits into
me2resh:devfrom
ahmed-bermawy:feature/1956-opencode-v2-plugin-adapter

Conversation

@ahmed-bermawy

@ahmed-bermawy ahmed-bermawy commented Nov 4, 2026 •

Copy link
Copy Markdown

Summary

  • Port the opencode gate adapter to the V2 plugin API, keeping the V1 path. One default export: { id: "apexyard.gates", setup, server }. opencode 2.x calls setup(ctx). opencode 1.3.4+ calls server().
  • The V2 handler receives one { tool, input } event. The ops root resolves from ctx.location.directory.
  • The hook payload now carries a top-level cwd: ctx.location.directory on V2 and input.directory on V1. The hook subprocess also runs there. A relative patch or edit target under workspace/<project> is now checked against that project, not against the ops root above it. When the session directory sits outside the ops root (APEXYARD_OPS_ROOT mode), both fall back to the ops root, where the bash checks can find their own wiring and cannot fail open.
  • V2 renamed the command tool bash to shell, and the edit/write file argument filePath to path. The adapter accepts both. On V1 it reads filePath only, so a decoy path cannot redirect the Edit/Write checks. A gate wire fires only on an exact tool-id match, so the wrong id fails open with no error anywhere.
  • opencode 2.x also ships a patch tool that writes files under the edit permission. It routes to .claude/hooks/dispatch-apply-patch.sh, which re-runs every Edit/Write check once per target path. Before this wire existed, a patch call ran no gate at all. A patch call with no envelope is now blocked rather than skipped.
  • The native session id ([Bug] An inherited CLAUDE_CODE_SESSION_ID overrides the adapter session id #1783) is carried from the V2 event. The unset CLAUDE_CODE_SESSION_ID prefix is always emitted. The export APEXYARD_SESSION_ID= line is emitted only when an id is known.
  • test/smoke-install-layout.sh asserts the V2 definition shape, the V1 server() member, and the registered execute.before hook.
  • bin/install-opencode-adapter.sh now parses both real version shapes (1.17.16 and opencode v2.1.4). It warns only below 1.3.4, the first release verified to load an object entrypoint. The previous pattern required an opencode prefix, so it never fired on a real 1.x install.
  • @opencode/plugin is a devDependency. It is imported type-only, so the installed plugin needs no runtime dependency.
  • The design change is recorded in an Update (#1956) section of AgDR-0092.

Testing

  1. cd harness-adapters/opencode && npm test passes, 84/84. That covers patch routing, patch cwd, the ops-root fallback, the no-envelope and no-path blocks, the dual entrypoint, V1 filePath-only, the no-session prefix, a subagent-shaped event, the host-tool wiring warning, and the installer's version guard.
  2. npm run typecheck is clean against the installed @opencode/plugin (2.2.5 resolved from the ^2.1.4 floor).
  3. bash test/smoke-install-layout.sh passes. The installed layout exports only default. That default is a { id, setup } definition, exposes server(), and its setup registers execute.before.
  4. bash test/smoke-block-unreviewed-merge.sh passes, with the negative control.
  5. The 1.3.4 floor is measured, not inferred. I downloaded and ran the release binaries with an object-entrypoint probe. 1.3.3 fails with fn3 is not a function. 1.3.4 loads the object and calls server(). 1.15.0, 1.16.0, 1.17.0, and 1.17.16 also load it.
  6. Live, against opencode 2.1.4. A real agent turn's fabricated gh pr merge 999999 --repo <nonexistent-fixture> --squash was refused by the unmodified block-unreviewed-merge.sh. The whole chain ran: loader, setup, a real shell event, the derived wires, event.input.command, then the hook.
  7. bash bin/run-pre-push-checks.sh passes.

Glossary

Term Definition
V2 plugin API opencode 2.x's plugin contract. A plugin default-exports a definition object, not a factory function.
plugin definition The { id, setup } object opencode 2.x validates. id is stable, and it also scopes plugin storage.
server() The V1 object-entrypoint member opencode 1.3.4+ calls instead of setup(). It returns the V1 hooks map.
execute.before opencode V2's pre-execution tool hook. A handler denies a tool call by throwing. V1 exposed this as "tool.execute.before".
gate wire One (tool, command glob) pair from .claude/settings.json. A gate fires only when a wire matches the call's tool id.
cwd The top-level stdin field that names the directory a hook resolves relative targets against. The adapter sets it to the host's session directory and runs the hook there.
patch routing The one hand-added wire, pointing an opencode patch call at dispatch-apply-patch.sh. settings.json cannot express it, because Claude Code and Codex reach apply_patch through a flag this adapter cannot pass.
fail open A gate that never matches. The tool call proceeds unguarded, and nothing reports it.

Closes #1956

opencode 2.x rejects a V1 plugin whose default export is a bare
`async (input) => hooks` factory:

  Plugin must export a default definition with an id and an effect or
  setup function. (cause: SchemaError(Expected object at ["default"]))

The loader validates the default export as `{ id, setup }` (or
`{ id, effect }`), so the adapter has to be ported, not reconfigured.
Until now the gateway silently failed OPEN: opencode logged a WARN and
carried on, so none of the bash gates ran.

- the default export is now `{ id: "apexyard.gates", setup }`
- the hook is registered with `ctx.tool.hook("execute.before", ...)`
- the handler receives one `{ tool, input }` event, not `(input, output)`
- the ops root resolves from `ctx.location.directory`
- V2 renamed the command tool bash -> shell and the edit/write file
  argument filePath -> path. Both vocabularies are accepted, because a
  gate wire only fires on an exact tool-id match: the wrong id would
  leave every Bash-wired gate matching nothing, with no error anywhere
- the native session id (me2resh#1783) is carried from the V2 event, so the
  marker-path and APEXYARD_SESSION_ID behaviour is unchanged
- the install-layout smoke test now asserts the V2 definition shape and
  that setup registers a hook, so this regression cannot return silently

Verified live against opencode 2.1.4. A real agent turn's fabricated
`gh pr merge` was refused by the unmodified block-unreviewed-merge.sh
through the V2 dispatch path: loader -> setup -> shell event -> wires ->
event.input.command -> hook.

Refs me2resh#1956
Advisory writing-profile cleanup on the paragraph this PR touches: no
semicolon in prose, and no sentence over 25 words.

@ahmed-bermawy ahmed-bermawy left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Verdict: CHANGES REQUESTED. Five acceptance criteria are not met. The V2 transport itself is sound: the hook registers, the handler denies, and the #1783 session-id path is preserved. Fix the criteria below, then request a delta re-review.

Code Review: PR #1959

Commit: 924dc85ba4661d207e6fab4eb922f2f151e3cfbf
Scope: Full

Summary

Ports harness-adapters/opencode/ from the opencode V1 plugin API to V2. The default export is now { id: "apexyard.gates", setup }. setup registers ctx.tool.hook("execute.before", …) and the handler receives one { tool, input } event. Bash maps to both shell and bash, and the edit/write argument is read as path then filePath. Adapter version moves 0.1.0 → 0.2.0.

Acceptance Criteria

Issue #1956 was created before the PR, has no comments, and its criteria are unchanged.

Issue Criterion Status Evidence
#1956 Default export is a single object that satisfies both plugin APIs: the V1 factory under server(), and the V2 Plugin.define({ id, setup }) spread into the same object Not met src/gate-dispatcher.ts:399 exports a plain { id, setup } object. There is no Plugin.define(...) spread and no server() member. The vendor documents this exact shape under "Support V1 and V2 from one package" in its V1 migration guide.
#1956 Under V2 the adapter registers through ctx.tool.hook("execute.before", ...) and throws when a gate exits 2 Met src/gate-dispatcher.ts:401 registers it. src/gate-dispatcher.ts:298 throws on a block. test/gate-dispatcher.test.ts:185 asserts the throw.
#1956 Tool-id mapping covers V2: shell maps to the bash gate set, bash keeps working, edit/write keep their mapping Met src/derive-gates.ts:151 maps Bash: ["shell", "bash"]. src/derive-gates.ts:269-282 keeps edit/write and reads path then filePath. test/derive-gates.test.ts:155-156 asserts both ids match.
#1956 The patch tool has a decided, tested behaviour: either run the Edit-wired gates with a reconstructed file_path set, or be documented as unrouted with a log line. No undocumented silent skip Not met The diff contains no patch handling, no decision, and no log line. V2's tools documentation lists patch with a patchText argument and edit permission over every affected path. findUnsupportedGateWires (src/derive-gates.ts:336) cannot see it, because no wire is derived for that tool.
#1956 Gate logic stays in gate-dispatcher.ts and derive-gates.ts; the change adds a transport only Met All gate matching and execution stays in those two files. src/index.ts remains a re-export shim.
#1956 V1 does not regress: the existing suite passes and a V1-shaped plugin still loads Not met opencode 1.x calls the default export as a factory, so the new object cannot load there. harness-adapters/opencode/README.md:136 and :142 state 1.x is unsupported and tell readers to pin 0.1.0. No test loads a V1 shape.
#1956 New tests cover the V2 path: allow, deny (exit 2 throws), ops-root resolution, session id, tool-id mapping Met test/gate-dispatcher.test.ts:185 (deny), :196 (allow), :266 (ops root from ctx.location.directory). test/session-id.test.ts covers the native id. test/derive-gates.test.ts covers the id mapping.
#1956 harness-adapters/opencode/README.md, docs/harnesses/opencode.md, and docs/opencode-adapter.md state that V1 and V2 are both supported, and that V2 requires directory paths for configured local plugins Not met docs/harnesses/opencode.md is untouched and still says tool.execute.before (:9, :13), filePath (:25), and "43 hermetic unit tests" (:13). The two edited docs say V2-only, not both supported. Only the README states the directory requirement.
#1956 The batch bypass note is removed Not met The note remains at harness-adapters/opencode/README.md:143 and docs/opencode-adapter.md:59,65. The cited issue, anomalyco/opencode#5894, is closed as completed. Its subject is subagent tool calls bypassing hooks, not a batch tool.

Checklist Results

  • Architecture & Design: ✅ Pass. The dispatcher/transport split is preserved, and registerGateDispatcher narrows the opencode context to a structural GateDispatcherContext instead of importing the whole server client.
  • Code Quality: ✅ Pass. The tool-input boundary stays unknown and is narrowed defensively. Fail-closed execution (runGateHook) is unchanged. No bare any in the diff.
  • Testing: ⚠ Advisory. 56/56 pass and the V2 event shape is genuinely exercised, not V1 renamed. Gaps are listed under Suggestions.
  • Security: N/A for this pass. Hakim owns the OWASP and gate-integrity checklist. Observed only: fail-closed behaviour survives the port. The patch coverage hole is recorded under AC4.
  • Performance: ✅ Pass. Ops-root resolution and gate derivation still run once per setup, so per-call cost is unchanged.
  • PR Description & Glossary: ✅ Pass. The glossary defines plugin definition, gate wire, and fail open. The body links Closes #1956.
  • Summary Bullet Narrative: ✅ Pass. Each bullet states what changed and why it matters.
  • Technical Decisions (AgDR): N/A, with reason. The dependency swap is forced by the vendor's API generation and the adapter design is already recorded in AgDR-0092. Dropping V1 support is a compatibility decision the ticket should capture, not a new architecture choice.
  • Adopter Handbooks: ⚠ Advisory. handbooks/language/typescript/strict-mode.md passes: tsconfig.json sets strict: true and the diff adds no bare any. handbooks/general/commit-message-quality.md raises one nit, below.
  • Fallow Static Analysis (JS/TS): N/A. The fallow CLI is not installed in this environment.

Issues Found

⛔ F1 — AC1 not met. harness-adapters/opencode/src/gate-dispatcher.ts:399. The default export is V2-only. The ticket asks for one object that serves both APIs, and the vendor migration guide shows the shape: spread Plugin.define({ id, setup }) and add async server() { return { "tool.execute.before": … } }. Add the server() member, or amend criterion 1 and record why the dual shape was rejected.

⛔ F2 — AC6 not met. harness-adapters/opencode/src/gate-dispatcher.ts:399. opencode 1.x calls the default export and this object is not callable, so V1 no longer loads. README.md:136 and :142 admit the regression without satisfying the criterion. The server() member in F1 fixes both criteria. Add a test that loads the V1 shape.

⛔ F3 — AC4 not met. harness-adapters/opencode/src/derive-gates.ts:296. V2's patch tool can add, update, move, and delete files, and it is controlled by the edit permission. Neither buildToolInput nor the wire table knows it, and no init warning names it. Under V2 any GPT model that reaches patch skips require-active-ticket.sh, block-protected-target-writes.sh, require-migration-ticket.sh, and the other Edit/Write gates, with no log line. This is not a regression from V1, where apply_patch had the same hole. It is still an unmet criterion in a PR that exists to close V2 gaps. Map patch onto the Edit wire set with a file_path set parsed from the patchText markers, or write an explicit unrouted warning at init. Add a test for whichever you choose.

⛔ F4 — AC8 not met. docs/harnesses/opencode.md:9,13,25. The harness support page is unchanged. It now misdescribes the shipped adapter: V1 hook name, V1 argument name, and a stale test count of 43. It also carries the V1-only loader-crash explanation with no V2 note. Update all three docs to one support matrix. State whether V1 is supported, which adapter version serves it, and that V2 configured local paths must be directories.

⛔ F5 — AC9 not met. harness-adapters/opencode/README.md:143, docs/opencode-adapter.md:59 and :65. The batch bypass note is still there. Delete it from all three locations. The cited issue is closed and describes subagent hook bypass, so leaving it also misattributes upstream history.

Suggestions

  • docs/opencode-adapter.md:55 says the API surface was verified against "the real, installed @opencode/[email protected]". package-lock.json resolves 2.2.5, and npm run typecheck ran against 2.2.5. I compared the 2.1.4 and 2.2.5 plugin.d.ts and tool.d.ts for the surface this adapter uses and found no difference, so the floor ^2.1.4 is safe. Correct the sentence to name the version that was actually installed, or pin exactly.
  • The PR body says the #1783 session-id behaviour is unchanged. It is unchanged in the handler, and a unit test proves the handler mutates event.input.command (test/session-id.test.ts:41-50). That opencode then executes the rewritten command rests on the vendor's "one mutable event" statement, not on the live run. The live proof in the body covers the deny path only. Add one live check that the rewritten command still exports APEXYARD_SESSION_ID, or narrow the claim.
  • src/gate-dispatcher.ts:302 adds a sessionId !== undefined guard before the command rewrite. No test drives a handler event without sessionID; test/session-id.test.ts:112 exercises execGateHookReal directly instead. Add a handler-level test for the no-session path.
  • The V1 compatibility path has no handler-level test. bash and filePath are proven only at the buildToolInput and gateMatchesToolCall level. Once F1 lands, add one event through the handler.
  • The ticket's design notes ask for a subagent-shaped event test, because a subagent carries a different session id. It is absent. Add it when you touch test/session-id.test.ts.
  • README.md:136 and bin/install-opencode-adapter.sh:170 tell readers to pin "adapter 0.1.0". The package is private: true and no adapter-level tag exists, so the instruction names no installable artifact. Name the framework release that carries 0.1.0, or drop the instruction in favour of F1's dual export.
  • handbooks/general/commit-message-quality.md: both commit subjects omit the (#1956) token that .claude/rules/git-conventions.md requires (feat: …, docs: …). The bodies are good and do explain why. This is advisory only.

Validation

Ran in /home/ahmed/my_work/apexyard-opencode-v2 at 924dc85:

  • npm test in harness-adapters/opencode → 56 passed, 0 failed.
  • npm run typecheck → clean, resolved against @opencode/[email protected].
  • bash test/smoke-install-layout.sh → PASS. Export keys are ["default"], the default is an object with id: apexyard.gates, and setup registered execute.before.
  • bash test/smoke-block-unreviewed-merge.sh → PASS, including the negative control.
  • Read the real V2 types in node_modules/@opencode/plugin/dist/promise/{plugin,tool}.d.ts, and the vendor V2 pages for plugins, tools, and the V1 migration guide.
  • Checked .claude/settings.json wiring to name the gates that patch would bypass.

Verification limits:

  • gh pr checks 1959 reports "no checks reported on the 'feature/1956-opencode-v2-plugin-adapter' branch", although .github/workflows/adapter-tests.yml matches harness-adapters/**. No CI result exists for this head SHA, so the local runs above are the only test evidence.
  • The live opencode 2.1.4 verification in the PR body was not reproduced. The opencode on PATH here is v2.1.4, which is consistent with the claim.
  • shellcheck is not installed here, so the changed shell scripts are unchecked.
  • docs/harnesses/opencode.md was checked for staleness, not re-reviewed for historical accuracy.

Verdict

CHANGES REQUESTED

The port works and the V2 shape is real, not V1 renamed. Five of nine acceptance criteria are unmet, and three of them (AC4, AC6, AC9) are gaps the ticket exists to close. F1 and F2 share one fix: add the vendor's server() member to the default export. F3, F4, and F5 are small and local.


🤖 Reviewed by Rex (Code Reviewer Agent)
📌 Reviewed commit: 924dc85ba4661d207e6fab4eb922f2f151e3cfbf

Addresses the Rex + Hakim review on me2resh#1959.

- `patch`: opencode 2.x ships a `patch` tool that adds, updates, moves and
  deletes files under the `edit` permission. It matched no gate at all, so
  `require-active-ticket`, `block-protected-target-writes`,
  `require-migration-ticket` and the reviewer-marker guards were skipped
  silently. It is now routed to `.claude/hooks/dispatch-apply-patch.sh`,
  which re-runs every Edit/Write check once per target path. `patch` and
  `apply_patch` both map, and `buildToolInput` hands the envelope over as
  `command`.
- Dual entrypoint: the default export keeps `{ id, setup }` for V2 and adds
  `server()` for opencode 1.18.29+. One package, both generations. The
  object is written literally because `Plugin.define` is identity and a
  runtime import of `@opencode/plugin` would make the file unloadable on
  1.x, where that package does not exist.
- Session id: the `unset CLAUDE_CODE_SESSION_ID` prefix is now always
  emitted, even when no native id is present, so an inherited Claude Code
  id cannot win the marker-path resolver again (me2resh#1783).
- Init warning: the wiring check now also names a file-writing tool the
  host registers but no wire matches. That hole cannot be found by walking
  the gate table.
- `@opencode/plugin` moves to devDependencies. It is imported type-only,
  so the installed plugin needs no runtime dependency, and the 302-package
  install surface leaves the governance host.
- Installer rejects an empty or path-escaping `--name` before `rm -rf`.
- Docs: all three pages state the V1 + V2 matrix and the V2 directory
  requirement for configured local plugin paths. The stale `batch` bypass
  note is removed; anomalyco/opencode#5894 is closed and describes subagent
  hook bypass, not a `batch` tool.

Tests: 68 pass (patch routing, dual entrypoint, no-session prefix, plus the
existing V2 suite). Smoke scripts pass, including the new `server()` check.
The ticket's design note asked for a subagent event, since a subagent runs
under its own session id. The event carries agent, messageID and id as well
as tool/sessionID/input; the handler must ignore the extras and pass the
child session id through to the hook.
@ahmed-bermawy

Copy link
Copy Markdown
Author

Review response — 1549e20

Both reviews returned CHANGES REQUESTED. Here is what changed, and the one item declined.

Blocking: patch ran no gate (Rex F3 / Hakim F1) — fixed

Confirmed. opencode 2.x registers patch with permission: "edit", and the adapter derived no wire for it, so require-active-ticket, block-protected-target-writes, require-migration-ticket and the reviewer-marker guards were skipped with no diagnostic.

patch and apply_patch now map to .claude/hooks/dispatch-apply-patch.sh, the framework's existing bash patch dispatcher. It re-runs every Edit/Write check once per target path. buildToolInput hands the envelope over as command, which is the field that script reads. The wire is added in deriveGatesFromOpsRoot rather than derived from settings.json, because Claude Code and Codex reach apply_patch through the * matcher and dispatch-bash.sh --shell-alias-only. This adapter runs hooks with no arguments, so it calls the dispatcher directly.

Tests: deriveGatesFromOpsRoot yields the wire, a patch call reaches the hook as tool_name: "apply_patch" with the envelope in tool_input.command, and a blocking hook denies the call.

I also took Hakim F2. The init-time warning now asks the host for its tool list and names any file-writing tool the host registers that no wire matches. That hole cannot be found by walking the gate table.

Dual V1 + V2 entrypoint (Rex F1/F2, AC1/AC6) — fixed

The default export is now { id: "apexyard.gates", setup, server }. setup() serves 2.x; server() returns { "tool.execute.before": … } for opencode 1.18.29+.

The object is written literally rather than through Plugin.define. That function is an identity function, and a runtime import of @opencode/plugin would make the same file unloadable on 1.x, where the package does not exist. import type is erased, so the V1 path keeps zero runtime dependencies.

Tests: the default export exposes both members; server() returns a working hook; the V1 handler blocks through the shared cascade.

Docs (Rex F4, AC8) — fixed

docs/harnesses/opencode.md now carries a support matrix (V2 setup / V1 server(), per-generation tool ids), the directory requirement for configured local plugin paths, and a corrected test count. It no longer describes only the V1 hook name, the V1 argument name, or a V1-only loader crash. README.md and docs/opencode-adapter.md state the same matrix.

batch note (Rex F5, AC9) — removed

I checked anomalyco/opencode#5894 before deleting it: CLOSED, titled "Plugin hooks (tool.execute.before) don't intercept subagent tool calls - security policy bypass". It is not a batch tool. The note was wrong on both counts, so it is gone from all three places.

Session-id prefix (Hakim F4) — fixed

unset CLAUDE_CODE_SESSION_ID is now always emitted, even when the event carries no sessionID. Only the export APEXYARD_SESSION_ID= line is conditional. Tests cover both branches.

On the live claim: Hakim's probe 6 confirmed that opencode applies a mutation made to event.input.command inside the hook, so the prefix path is live-verified at the dispatch layer. The deny path is additionally live-verified end to end.

Dependency surface (Hakim F5) — fixed

@opencode/plugin moved to devDependencies. It is imported type-only, the installed plugin has no runtime import of it, and npm run typecheck still resolves it.

Installer (Hakim F6) — partly fixed

An empty, ., .., or slash-containing --name is now rejected before the rm -rf. The opencode --version probe still runs the binary on PATH; that is the same trust the rest of the script already has, and it only reads a version string.

Declined for this PR: Hakim F3 (check-secrets.sh echoes the matched text)

Real finding, and outside this diff: the behaviour is unchanged by the port. It belongs in the secrets hook, not in a transport change, so I would rather see it as its own ticket than smuggled in here.

Other

  • The typecheck that ran resolved @opencode/plugin 2.2.5 from the ^2.1.4 floor. The docs now name both, rather than claiming 2.1.4 was the installed version.
  • 69 hermetic tests pass. Both smoke scripts pass, and the install-layout one now also asserts the server() member.
  • CI on this head SHA sits at action_required, so a maintainer needs to approve the workflow run before the checks report.

Second delta from the Rex review on me2resh#1959.

- The installer warned that "a V2 definition will not load on opencode 1.x"
  and told 1.x users to pin 0.1.0. That is false now: the shipped object
  carries `server()`, which 1.18.29+ calls. The warning now fires only for a
  1.x older than 1.18.29, the only version that cannot load an object
  entrypoint.
- `docs/harnesses/opencode.md` claimed live proof on both generations. The
  V2 path is live-proven; the V1 `server()` path is unit- and smoke-covered.
  It now says so.
- "There is no hand-maintained gate list" was no longer true: the patch wire
  cannot come from settings.json and is added explicitly. Both docs name that
  single exception.
- The derive-gates comments said the patch wire pointed at `dispatch-bash.sh`
  and that the envelope went there. It points at `dispatch-apply-patch.sh`,
  because the adapter cannot pass `--shell-alias-only`.
- `patchRoutingGates` now skips itself when a fork already wires an
  `apply_patch` gate for the same hook, so the hook cannot run twice.
- Register the hook before the advisory wiring warnings. A host that throws
  synchronously from `tool.list()` can no longer leave the session with no
  gate registered.
- Tests: 70. Adds coverage for the host-tool-list warning branch.
@ahmed-bermawy

Copy link
Copy Markdown
Author

Delta response — 7358f99

All four blocking items are addressed. They were documentation and consistency defects, not code.

F1 — installer warned that 1.x cannot load the definition (false now). The install script only warns when the opencode on PATH predates 1.18.29, the one version that cannot load an object entrypoint. The header comment says the same, and the "pin 0.1.0" instruction is gone.

F2 — "Live-proven on both plugin generations" overclaimed. docs/harnesses/opencode.md now says: live-proven on opencode 2.1.4, with the July 2026 run recorded as the then-current 1.x API. It states that the shipped server() path is unit- and smoke-covered, not live-run, and that matches docs/opencode-adapter.md's "still open" line.

F3 — "There is no hand-maintained gate list" was false. Both pages now name the single exception: the patch-routing wire, which settings.json cannot express, and say why.

F4 — PR body was stale. Rewritten. It describes the dual-generation export, the patch routing, 70 tests, and the 1.18.29+ floor rather than "0.1.0 is the last 1.x build".

Advisories:

  • Test count corrected to 69 at the time of your pass, and now 70 after adding the host-tool-list test.
  • The derive-gates.ts comments said the patch wire pointed at dispatch-bash.sh. Corrected to dispatch-apply-patch.sh, and the reason the adapter cannot pass --shell-alias-only is recorded.
  • patchRoutingGates now skips itself when a fork already wires an apply_patch gate for the same hook, so the hook cannot run twice.
  • The *** Environment ID: leading line is a real false block, and it lives in .claude/hooks/dispatch-apply-patch.sh, not in this diff. It fails closed, so it denies rather than allows. I would rather file that against the patch dispatcher than widen this PR.
  • Register the hook before the advisory warnings (Hakim A1): done, including a synchronous-throw guard. The host-tool-list branch now has a test (Hakim A2).

Third delta from the Rex review on me2resh#1959.

- `patchRoutingGates` now skips itself only when an existing gate already
  wires the patch dispatcher for `patch`/`apply_patch`. Keying on the hook
  path alone would have dropped the patch wire in a fork that wires that
  script for `edit` only. Both branches now have tests.
- The installer version guard also covers a major version below 1. It
  previously stayed silent for `0.9.0`.
- The remaining docs now name the patch-routing exception, and attribute
  `apply_patch` to the Codex path that is evidenced in-repo rather than to
  Claude Code as well.
- Test count corrected to 70, then 72 with the two de-duplication tests.
@ahmed-bermawy

Copy link
Copy Markdown
Author

Final delta at e5ef40b: Rex's remaining advisories.

  • patchRoutingGates now de-duplicates on an existing patch/apply_patch wire rather than on the hook path. An edit-only wiring of that script keeps the patch wire. Both branches have tests.
  • The installer version guard also covers a major version below 1 (0.9.0 now warns).
  • The README and docs name the patch-routing exception; apply_patch is attributed to the Codex path that is evidenced in-repo.
  • Test count 72.

One advisory declined: dispatch-apply-patch.sh rejects a leading *** Environment ID: line that opencode's own patch parser accepts. That is a false block, and it lives in the bash hook, not this diff. It fails closed, so it denies rather than allows. Opening it against the patch dispatcher keeps this PR a transport change, as #1956 scopes it.

npm test 72/72, npm run typecheck, both smoke scripts and bin/run-pre-push-checks.sh pass at e5ef40b.

@atlas-apex

Copy link
Copy Markdown
Collaborator

@ahmed-bermawy thank you for #1959. The V2 port is clear, and routing the patch tool through the edit checks closes a real gap.

CI is green except one job: docs lint finds 29 writing faults in docs/opencode-adapter.md (16) and docs/harnesses/opencode.md (13). Most are passive voice (18) and sentences over 25 words (10). The full list is in the "Check all published docs (strict)" step of the docs lint run. You can run the same check locally with node bin/docs-lint.mjs --strict.

Please fix these and push. We will then start the code review.

docs lint (strict) reported 29 writing-profile faults in the two opencode
docs: 18 passive voice, 10 sentences over 25 words, 1 list step over 20
words. Both files now pass `node bin/docs-lint.mjs --strict` with zero
findings.

- docs/opencode-adapter.md (16): active voice for the rejection, the
  patch-routing wire, the loader's single-file skip, the layout invariant,
  the live chain and the V1 coverage; the long API-surface, live-chain and
  verification sentences split.
- docs/harnesses/opencode.md (13): same treatment for the V1 status line,
  the id-set acceptance, the imperative-API explanation, the delegated
  enforcement paragraph, the gate-derivation exception, the dispatcher
  description, and the flat-copy crash note.

No claims changed; wording only.
…flagged

The advisory writing-profile check (bin/check-writing-profile.sh) flags
semicolons in prose. Two were introduced by the docs-lint fix; split them.
The two remaining advisory findings in these files predate this branch.

`node bin/docs-lint.mjs --strict`: 0 findings.
@ahmed-bermawy

Copy link
Copy Markdown
Author

Fixed in 5023735.

  • docs/opencode-adapter.md: 16 findings cleared — active voice for the V1-factory rejection, the patch-routing wire, the loader's single-file skip, the layout invariant, the live chain, and the V1 coverage; the long API-surface, live-chain, and verification sentences split.
  • docs/harnesses/opencode.md: 13 findings cleared — same treatment for the V1 status line, id-set acceptance, the imperative-API explanation, the delegated-enforcement paragraph, the gate-derivation exception, the dispatcher description, and the flat-copy crash note.

No claim changed; wording only. node bin/docs-lint.mjs --strict reports 0 findings locally (was 29). The advisory bin/check-writing-profile.sh dropped from 5 to 3 in these files; I split the two semicolons my rewrite introduced, and the remaining three predate this branch.

The CI runs on 5023735 are queued as action_required and need your approval before they execute.

me2resh
me2resh previously approved these changes Nov 5, 2026
@me2resh
me2resh self-requested a review November 5, 2026 05:38

@me2resh me2resh left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Security Review: PR #1959

Commit: 5023735668aaebaf6517534689dbbef4e13de2ce
Scope: Full (external contribution, issue #1956)

Summary

This PR ports the opencode gate adapter to the V2 plugin API. It keeps a V1 server() entrypoint. It adds the V2 tool ids (shell, patch) and the V2 argument name (path). It routes patch and apply_patch to .claude/hooks/dispatch-apply-patch.sh. The PR changes no file under .claude/. No existing hook or settings wire changes.

Most of the port is sound. The hook spawn uses execFileSync("bash", [hookPath]) with the payload on stdin, so tool input never reaches a shell unquoted. A spawn error, a timeout or a buffer overflow still fails closed. The session-id prefix quotes the id. The lockfile is clean. One correctness fault remains in the new patch route.

CI for the head SHA is green (one read).

Checklist Results

  • Secrets & Credentials: Pass
  • Injection Prevention: Pass. Hooks run through execFileSync with an argument vector. The APEXYARD_SESSION_ID export uses POSIX single-quote escaping. The unset CLAUDE_CODE_SESSION_ID line is now always emitted, which is correct for #1783.
  • XSS Prevention: N/A
  • Auth & Authorisation: Fail. See finding 1.
  • Data Protection: Pass
  • API Security: N/A
  • Gate & Trust-Chain Integrity: Fail. See finding 1. Compared with dev, no gate is weakened on the shell/bash path. The patch route closes a real hole, because on dev a V2 patch call matched no gate at all.
  • Supply chain: Pass. package-lock.json has 302 packages. All of them resolve from registry.npmjs.org, all have an integrity hash, none has an install script, and all are dev. @opencode/plugin is imported with import type only, so nothing from it runs at runtime.
  • Installer: Pass. bin/install-opencode-adapter.sh downloads nothing. It copies five local files into <target>/<name>/ with default permissions. The new --name guard stops rm -rf on the target dir itself. It runs opencode --version only when opencode is on PATH.

Security Issues Found

1. HIGH (blocking, kind 3): the patch route checks relative targets against the ops root, not against the directory opencode writes into.

runGateHook sends {tool_name, tool_input} with no top-level cwd. It spawns every hook with cwd: opsRoot (gate-dispatcher.ts, runGateHook and execGateHookReal). dispatch-apply-patch.sh resolves its working directory from .cwd // .tool_input.cwd // CLAUDE_PROJECT_DIR // PWD. Under the adapter all of these are absent except PWD, which is the ops root. The per-target Edit/Write checks then run from the ops root. require-active-ticket.sh joins a relative file_path to $PWD. block-protected-target-writes.sh joins it to the same fallback.

A patch envelope carries relative paths. The PR's own test uses *** Update File: src/a.ts. The README tells the operator to install the adapter into the project that opencode must govern. In that layout, ctx.location.directory is workspace/<project>, and the walk-up resolves the ops root above it. A plain, uncrafted patch that writes workspace/<project>/src/a.ts is then judged as <ops-root>/src/a.ts.

The effects are these:

  • The ticket gate looks up the marker of the wrong tree. When the ops fork has an active ticket and the project has none, the write is allowed. In the opposite case, the write is blocked.
  • A .. target from a session directory below the ops root resolves to a different absolute path than the one opencode writes. The protected-target and reviewer-write checks then evaluate the wrong file.
  • The Codex and Claude Code payloads both carry cwd, and dispatch-apply-patch.sh reads it first. The hook needs that field. The adapter does not send it.

Proposed fix: pass ctx.location.directory (V1: input.directory) into evaluateToolCall, and add it as the top-level cwd field of the stdin payload. Add a test that checks cwd in the payload for a patch call. Optionally add a fixture test in which a relative target under a session directory other than the ops root reaches the real dispatch-apply-patch.sh.

Assumption to confirm: opencode resolves patch paths against the instance directory. The V2 edit/write path argument has the same exposure when the host accepts a relative path, so the same cwd field covers it.

Recommendations (advisory, non-blocking)

A1. MEDIUM: a decoy path key can redirect the Edit/Write checks on the V1 entrypoint. buildToolInput reads raw.path ?? raw.filePath. The V1 tools act on filePath. On dev the adapter read filePath only. A V1 call that carries both keys gets the check on path while the tool writes filePath. This works only when the host passes the unvalidated arguments to the hook. It needs a crafted call, so it is advisory. Proposed fix: when both keys are present and differ, block. As an alternative, run the gates once for each value.

A2. MEDIUM: tool-id normalisation differs between the warning and the dispatcher. warnOnWiringGaps strips a dotted prefix (tool.id.split(".").pop()). The dispatcher matches event.tool exactly. If a host ever emits a namespaced id, every gate misses, and the warning reports the tool as routed. There is also no warning for an unrouted command tool. Proposed fix: normalise ids the same way in both places. Also warn once on the first event whose tool id is unknown.

A3. LOW (not changed by this PR): the walk-up trusts the first ancestor that has an anchor. A managed repo that ships .apexyard-fork and .claude/ becomes the ops root. Its settings.json then defines the gate table, and its hooks run. The logic is the same as on dev. opencode already loads project plugins from such a repo, so this adds little exposure. Consider a preference for APEXYARD_OPS_ROOT, or a check against the registered fork, in a later change.

Verdict

CHANGES REQUESTED. Finding 1 is a correctness fault in the new patch gate route. The fix is small: add one payload field and one test.

Reviewer note: one probe was not run. A command that built a fixture ops root in the review scratchpad was blocked by block-reviewer-repo-mutation.sh. Finding 1 rests on reading the adapter and the hook code, not on a live run.


🛡️ Reviewed by Hakim (Security Auditor)
📌 Reviewed commit: 5023735668aaebaf6517534689dbbef4e13de2ce

@me2resh me2resh left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

CHANGES REQUESTED. Three findings block this PR. Fix them, then ask for a delta re-review. The V2 transport is sound: every Bash-wired gate maps to both shell and bash, and no gate that fired under V1 stops firing.

Code Review: PR #1959

Commit: 5023735668aaebaf6517534689dbbef4e13de2ce
Scope: Full (external contribution)

Summary

The PR ports harness-adapters/opencode to the opencode V2 plugin API and keeps a V1 entrypoint. The default export is { id: "apexyard.gates", setup, server } (src/gate-dispatcher.ts:604-608). setup registers ctx.tool.hook("execute.before", ...). server returns the V1 "tool.execute.before" hook. Both use one shared cascade, evaluateToolCall (src/gate-dispatcher.ts:307-342). The Bash matcher maps to shell and bash (src/derive-gates.ts:160). Edit and write read path, then filePath (src/derive-gates.ts:299). A new hand-added wire routes patch and apply_patch to .claude/hooks/dispatch-apply-patch.sh (src/gate-dispatcher.ts:460-482). @opencode/plugin replaces @opencode-ai/plugin and becomes a type-only devDependency.

Gate-wiring check:

  • All 57 APEXYARD_DISPATCH_GATE rows in dispatch-bash.sh use the Bash matcher. Each row now yields a shell wire and a bash wire (src/derive-gates.ts:227-229).
  • The settings.json Edit/Write rows keep edit and write (src/derive-gates.ts:161-163).
  • The V1 handler still reads output.args and the bash id. No V1 wire was removed.

Acceptance Criteria

Issue #1956 has no comments. Its criteria are as filed.

Issue Criterion Status Evidence
#1956 Depends on the V2 plugin package. The default export is one object with the V1 factory under server() and the V2 Plugin.define({ id, setup }) spread into it Met package.json devDependency @opencode/plugin ^2.1.4. src/gate-dispatcher.ts:604-608 is one object with id, setup and server. The literal object replaces the Plugin.define spread. The reason is at :596-601: a runtime import of @opencode/plugin would break 1.x. The claim that Plugin.define is an identity function is Unverified (the package is not installed in the review tree).
#1956 Under V2, registers through ctx.tool.hook("execute.before", ...) and throws when a gate exits 2 Met src/gate-dispatcher.ts:507, :333. Tests at test/gate-dispatcher.test.ts:187, :239.
#1956 shell maps to the bash gate set, bash keeps working for V1, edit/write keep their mapping Met src/derive-gates.ts:159-168. Tests at test/gate-dispatcher.test.ts:187, :469.
#1956 patch has a decided, tested behaviour. No undocumented silent skip Met, with finding B1 Routing at src/gate-dispatcher.ts:460-482. Tests at test/gate-dispatcher.test.ts:409, :425, :442. B1 shows that relative targets are checked against the wrong tree. Advisory A3 covers a missing envelope.
#1956 Gate logic stays in gate-dispatcher.ts and derive-gates.ts. Transport only Met The diff changes no file under .claude/. No hook or settings wire changes.
#1956 V1 does not regress: the existing suite passes and a V1-shaped plugin still loads Met CI opencode unit tests passed. test/gate-dispatcher.test.ts:460, :469. The smoke script asserts HAS_V1_SERVER. My read of the local opencode 1.17.16 binary shows a loader that accepts an object default with id and server() (Inferred, not executed). A live 1.x run is still open, as docs/opencode-adapter.md:68 states.
#1956 New tests cover V2: allow, deny, ops-root resolution, session id, tool-id mapping Met Allow :198, deny :187, ops root :268, session id :495, :506, :519, mapping test/derive-gates.test.ts:247, :307.
#1956 The three docs state that V1 and V2 are both supported, and that V2 requires directory paths for local plugins Met harness-adapters/opencode/README.md:5, :36. docs/harnesses/opencode.md:7, :14. docs/opencode-adapter.md:5, :26. The stated 1.x floor is questioned in B2.
#1956 The batch bypass note is removed Met Removed from the README and docs/opencode-adapter.md. docs/agdr/AgDR-0092-opencode-gate-adapter.md:37 still carries it (see B3).

Checklist Results

  • Architecture & Design: ⚠ Advisory. The transport stays thin and shares one cascade for both generations. The patch wire is the first hand-maintained gate entry in an adapter whose AgDR says it derives every gate (B3).
  • Code Quality: ❌ Fail. B2 is a dead version guard in bin/install-opencode-adapter.sh. The rest is clear, with careful fail-closed handling for exec errors.
  • Testing: ⚠ Advisory. V2 allow, deny, session id, patch routing and the dual entrypoint are covered. No test covers the installer version guard (B2), a patch call without an envelope (A3), or the stdin cwd field (B1).
  • Security: ❌ Fail. I concur with Hakim's finding 1 at this head (B1). I did not re-run the OWASP checklist.
  • Performance: ✅ Pass. Each shell or bash call matches only its own wire. The extra wire per matcher adds no hook run.
  • PR Description & Glossary: ✅ Pass. The summary, testing steps and glossary are present. Closes #1956 links the ticket. Two statements need correction: the 1.18.29 floor (B2) and the coverage of the smoke scripts (see Validation and A4).
  • Summary Bullet Narrative: ✅ Pass. Each bullet states the change and why it matters.
  • Technical Decisions (AgDR): ❌ Fail. See B3.
  • Adopter Handbooks: ⚠ Advisory. handbooks/language/typescript/strict-mode applies (A6). handbooks/general/commit-message-quality applies and passes: every commit has a body. The private general/org-engineering-handbook applies and has no finding for a harness transport. The architecture handbooks do not apply: there is no domain, application or infrastructure layer here, and no migration path.
  • Fallow Static Analysis (JS/TS): N/A. The fallow CLI is not installed on the review machine, so the step is skipped.

Issues Found

B1. Blocking, kind 3. The patch route checks relative targets against the ops root, not the directory opencode writes into. I concur with Hakim's finding 1 on this head. I checked the code path myself.

  • runGateHook sends { tool_name, tool_input } with no cwd (src/gate-dispatcher.ts:200).
  • The hook runs with cwd: opsRoot (src/gate-dispatcher.ts:201, :140).
  • dispatch-apply-patch.sh:7-8 changes to .cwd, then .tool_input.cwd, then CLAUDE_PROJECT_DIR, then PWD. Under the adapter, only PWD is set, and it is the ops root.
  • require-active-ticket.sh resolves a relative file_path against the process working directory (comment at lines 535-537, join at line 749).
  • Patch envelopes carry relative paths. The PR's own test uses src/a.ts (test/gate-dispatcher.test.ts:431).

When opencode runs in workspace/<project>, a plain patch on src/a.ts is judged as <ops-root>/src/a.ts. The ticket gate can then allow a write that it should block, or block a write that it should allow. Basis: Inferred from the code. It was not exercised. This is a new route, so it is not a regression against dev, where patch ran no gate. But the route does not do what its comments and docs say it does.
Fix: pass ctx.location.directory (V1: input.directory) into evaluateToolCall and send it as a top-level cwd in the stdin payload. Add a test that asserts cwd in the payload of a patch call.

B2. Blocking, kind 3. The installer's version guard can never fire on real opencode output. bin/install-opencode-adapter.sh:176 parses the version with a pattern that requires a leading opencode word. The local opencode 1.17.16 prints the bare version 1.17.16. Observed:

  • Real output 1.17.16 gave an empty OC_VERSION. The guard then says nothing, at :178.
  • The form opencode v1.17.16 parses as 1.17.16.

So the warning never reaches the user it exists for. The PR body and the script header both say that it does.
A related claim needs evidence. The docs, the installer and the PR body say that a 1.x older than 1.18.29 "cannot load an object entrypoint at all". The opencode 1.17.16 binary on this machine contains an entrypoint check that accepts an object default with id and server() and calls server(). Basis: Inferred from reading the bundled loader code. It was not executed.
If that read is right, the stated floor is wrong, and the warning would tell working users to upgrade.
Fix: accept the bare version form. Confirm the real floor. Correct README.md:5, docs/harnesses/opencode.md:7 and :12, docs/opencode-adapter.md:5, the installer text and the PR body to match. Add a small test that stubs opencode --version.

B3. Blocking, § 7 AgDR. The V2 port changes decisions that AgDR-0092 records, and no AgDR is linked.

Decisions detected:

  • The dependency moves from @opencode-ai/plugin (runtime) to @opencode/plugin 2.x (type-only devDependency). The lock file grows to about 300 dev packages. AgDR-0092 line 28 names the old package.
  • One dual-generation default export (setup plus server), written as a literal instead of Plugin.define.
  • Both tool-id and argument vocabularies are carried side by side instead of probing the version. AgDR-0092 line 28 records bash and filePath as the verified ids.
  • A hand-maintained patch-routing wire is added. AgDR-0092 lines 3, 19 and 33 say that the adapter derives every gate with zero drift.

The threshold in .claude/rules/agdr-decisions.md counts a new dependency as material. A security-relevant control is material too, and this adapter is the enforcement transport for the gates. AgDR-0092 line 37 also still documents the batch bypass that #1956 removes.
Action: run /decide for a new AgDR, or add an "Update (#1956)" section to AgDR-0092, which already has an update section from #844. Link it in the PR description.

Handbook Findings

⚠ TypeScript Strict Mode — handbooks/language/typescript/strict-mode.md

  • rule-id: handbooks/language/typescript/strict-mode
  • src/gate-dispatcher.ts:365 casts (event.input ?? {}) as Record<string, unknown> with no runtime object check. A non-object input from the host would pass the cast. Add a typeof === "object" && !== null guard first. (A6)

Suggestions

  • A1 (concur with Hakim A1). buildToolInput reads raw.path ?? raw.filePath for both generations (src/derive-gates.ts:299). The V1 tools write filePath, and dev read only filePath. A V1 call that carries both keys gets checked on path. Suggestion: let the V1 handler prefer filePath, or block when both keys are present and differ.
  • A2 (concur with Hakim A2). warnOnWiringGaps strips a dotted prefix (src/gate-dispatcher.ts:551). The dispatcher matches event.tool exactly. Normalise the id the same way in both places.
  • A3. A patch call with no string envelope is skipped with no gate and no message (src/derive-gates.ts:312-313, src/gate-dispatcher.ts:327). The 1.17.16 host rejects an apply_patch call without patchText (Inferred from the bundled tool code), so this is not reachable there today. If the argument is renamed, the patch gates stop silently. Suggestion: block a patch-tool call that has no envelope, and add a test.
  • A4. CI runs npm ci and npm test only (.github/workflows/adapter-tests.yml). npm run typecheck and both smoke scripts are not in CI. The "real types" claim and the smoke results therefore rest on the author's local runs. This gap exists on dev too.
  • A5. The derive-gates.ts:277 comment says "V1 used filePath and bash". bash is a tool id, not an argument name. Reword it.
  • Positive note: the 1.17.16 binary already registers an apply_patch tool with patchText. On dev, that tool ran no gate under V1 either. This PR closes that gap for both generations.

Validation

  • CI at 5023735, read once: 12 checks passed and 2 were skipped (conformance, nightly-failure). opencode unit tests passed (18 s), and so did hook tests (linux jq-1.7), markdownlint-cli2 and records-lint.
  • I did not run the adapter suite or the smoke scripts. That would execute contributor code, and CI already ran npm test. To prove B1 and B2 after the fix, I would run node --test test/*.test.ts and bash test/smoke-install-layout.sh in a scratch export, and run the installer with a stub opencode that prints a bare 1.17.16.
  • Observed: the installer's sed expression gives an empty result on the real local opencode --version output (1.17.16). It parses opencode v1.17.16.
  • Inspected: the local opencode 1.17.16 bundle shows the object-entrypoint check (id plus server()), the tool.execute.before trigger with { tool, sessionID, callID } and { args }, and the apply_patch tool's patchText argument with the *** Begin Patch envelope.
  • Lock file, checked with a script over package-lock.json: 302 packages. All resolve from registry.npmjs.org, all have an integrity hash, and none has hasInstallScript. Every package is a dev dependency. No zod entry remains. @opencode/plugin resolves to 2.2.5.
  • Not verified: the V2 facts (shell, path, patch with patchText, the { tool, sessionID, input } event, ctx.location.directory). The @opencode/plugin package is not installed here, and no V2 host is available. The PR body reports a live V2 run on 2.1.4.

Verdict

CHANGES REQUESTED

B1 is a correctness fault in the new patch gate route. B2 is a guard that cannot fire, built on a version floor that the 1.17.16 loader appears to contradict. B3 is a blocking AgDR omission. The advisory items do not affect the verdict. Next action: the author fixes B1 to B3, then a delta re-review runs.


🤖 Reviewed by Rex (Code Reviewer Agent)
📌 Reviewed commit: 5023735668aaebaf6517534689dbbef4e13de2ce

@atlas-apex

Copy link
Copy Markdown
Collaborator

@ahmed-bermawy thank you for the docs fixes, and for the port itself. The code review and the security review are both done. The gate wiring is sound: every Bash route maps to both shell and bash, no gate that fired under V1 stops firing, command construction never passes tool input to a shell, and the lock file is clean. The new patch route closes a real gap.

Three changes are necessary before we can merge:

  1. Send the working directory to the hooks (both reviews found this). runGateHook sends only {tool_name, tool_input} and runs each hook with cwd: opsRoot. dispatch-apply-patch.sh reads .cwd first and then falls back to the ops root. So a relative patch target such as src/a.ts in workspace/<project> is checked as <ops-root>/src/a.ts. The ticket gate then reads the wrong tree, and it can allow or block the wrong write. Please send ctx.location.directory (V1: input.directory) as the top-level cwd in the payload, and add a test for a relative patch target. The same field also covers a relative V2 edit/write path.
  2. Fix the version check in bin/install-opencode-adapter.sh (line 176). The pattern expects the output to start with opencode, but opencode --version prints a bare version, for example 1.17.16, so the warning never fires. Also please check the stated 1.18.29 minimum. The 1.17.16 bundle appears to load an object default export with id and server() already. That was found by reading the bundle, not by running it. Please run it, and update the docs, the installer and the PR body to match what you see.
  3. Record the design change in AgDR-0092. This PR changes decisions recorded there: the type-only @opencode/plugin dev dependency, the dual V1/V2 export, and the hand-maintained patch wire. Please add an "Update ([Feature] opencode adapter: enforce the gates under OpenCode V2 #1956)" section to AgDR-0092.

Two small fixes are optional but welcome in the same push:

  • On V1, read filePath only, as dev does. Today path ?? filePath lets a decoy path next to filePath redirect the Edit/Write checks.
  • Block, or at least log, a patch call with no patch text. Today it skips all gates silently.

The other advisories are in the two review comments. After your push, we will do a short delta review of the new commits only.

Relative patch and edit targets were checked against the ops root instead of
the directory opencode writes into. Send ctx.location.directory (V1:
input.directory) as the payload cwd and run the hook there.

Also block a patch call with no envelope, read only filePath on V1, normalise
namespaced tool ids, correct the V1 floor to 1.3.4 (verified by running 1.3.3
and 1.3.4), and record the port in AgDR-0092.

Refs me2resh#1956
Split the semicolon joins and overlong sentences introduced with the me2resh#1956
AgDR update and the cwd documentation.

Refs me2resh#1956
@ahmed-bermawy

Copy link
Copy Markdown
Author

Round 3 delta — a2ddc9c

All three required changes and both optional fixes are in.

1. Send the working directory to the hooks — fixed

runGateHook now sends { tool_name, tool_input, cwd } and spawns the hook with that directory. registerGateDispatcher passes ctx.location.directory; createV1Hooks passes input.directory. dispatch-apply-patch.sh reads .cwd first, so a relative src/a.ts under workspace/<project> now resolves there. require-active-ticket.sh sees the same $PWD.

Test: a patch call's payload cwd and its subprocess cwd both equal the session directory, not the ops root.

2. Installer version guard — fixed, and the floor is measured

The sed now accepts a bare 1.17.16 and opencode v2.1.4. The floor is 1.3.4, measured by downloading and running the release binaries with an object-entrypoint probe:

  • 1.3.3 fails with fn3 is not a function. (In 'fn3(input)', 'fn3' is an instance of Object) — factory only.
  • 1.3.4 loads the object and calls server().
  • 1.15.0, 1.16.0, 1.17.0, 1.17.16 do the same.

The 1.18.29 claim was therefore wrong. The docs, installer, README and PR body now say 1.3.4. New test test/installer-version.test.ts stubs opencode --version for the bare form, the prefixed form, the floor, below the floor, and a pre-1.0 major.

3. AgDR-0092 — updated

Added an Update (#1956) section covering the dual literal export, the type-only devDependency swap, the hand-maintained patch wire, the dual tool vocabulary, the cwd payload, the measured 1.3.4 floor, and the withdrawal of the mistaken batch note.

Optional fixes

  • V1 edit/write now read filePath only. A decoy path cannot redirect the check.
  • A patch call with no envelope now blocks and names the problem instead of skipping every gate.

Advisories taken

  • Hakim A1 / Rex A1 (V1 filePath only) — fixed, test added.
  • Hakim A2 / Rex A2 (dotted tool ids) — the dispatcher normalises ids the same way the warning does; test added.
  • Rex A6 (event.input cast) — narrows to an object before the cast.
  • Rex A5 comment reworded.

npm test 81/81, npm run typecheck, both smoke scripts, node bin/docs-lint.mjs --strict (0 findings) and bin/run-pre-push-checks.sh pass at a2ddc9c.

@ahmed-bermawy ahmed-bermawy left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Security Review: PR #1959

Commit: a2ddc9cf84bd35e1c3acf34ca1164c0768977f43
Scope: Delta re-review 5023735..a2ddc9c (b4425b4, a2ddc9c), inside the full PR 3c2411c...a2ddc9c. External contribution from ahmed-bermawy/apexyard against dev.

Output Format

Verdict: APPROVED

Findings: no blocking finding. One MEDIUM advisory, three LOW advisories and one INFO note.

  • A1 MEDIUM harness-adapters/opencode/src/gate-dispatcher.ts:193 — the new callCwd parameter sits after two optional parameters, so a future caller can drop it silently.
  • A2 LOW harness-adapters/opencode/src/gate-dispatcher.ts:308 — a dotted id with an empty tail normalises to "" and matches no wire.
  • A3 LOW harness-adapters/opencode/src/derive-gates.ts:305 — an edit/write call with no target key still skips its gates without a message.
  • A4 LOW docs/harnesses/opencode.md:7 — the 1.3.4 floor evidence is not recorded in the repo.
  • A5 INFO — CI did not run on the head SHA. I ran the adapter suite, the type check and the install smoke locally.

Summary

This delta closes the blocking finding from the last review. The hook payload now carries a top-level cwd. The hook subprocess runs in that directory. The V1 handler reads filePath only. A patch call with no envelope blocks. Tool ids are normalised in one place. The installer parses both real version shapes and warns below 1.3.4. AgDR-0092 gains an Update (#1956) section.

No file under .claude/ changes. Confirmed with a name-only diff of the full PR. The gate logic stays in bash, and the adapter stays a transport.

Earlier findings

  • F1 (HIGH, blocking) — CLOSED. The payload cwd is ctx.location.directory on V2 and input.directory on V1. The hook subprocess runs there. I checked the claim against the upstream sources and not against the comment.
    • opencode v2.1.4 resolves patch hunk paths with FileAccess.resolvePath(location.directory, hunk.path) (packages/core/src/tool/plugin/patch.ts:90).
    • The plugin Context.location is Location.Info (packages/plugin/src/effect/plugin.ts:28).
    • The Location service sets directory: ref.directory (packages/core/src/location.ts:26).
    • So the value the adapter sends is the same root the patch tool writes into. The test at test/gate-dispatcher.test.ts:444 asserts both the payload field and the subprocess directory.
  • A1 (MEDIUM, decoy path on V1) — CLOSED. buildToolInput takes the generation and reads raw.filePath only on V1. Test at test/gate-dispatcher.test.ts:520.
  • A2 (MEDIUM, normalisation mismatch) — CLOSED for the mismatch. normaliseToolId now serves both the dispatcher and the warning. The missing "unknown tool id" warning remains, and it is advisory.
  • A3 (LOW, patch with no envelope) — CLOSED. The call throws with a named reason. Test at test/gate-dispatcher.test.ts:458.
  • Rex B2 and B3 — CLOSED. The version guard now parses real output, and the AgDR update records the changed decisions.

Checklist Results

  • Secrets & Credentials: Pass. No secret, key or token enters the new code.
  • Injection Prevention: Pass. Hooks still run through execFileSync("bash", [hookPath]) with the payload on stdin. The new cwd travels as JSON and is used in quoted positions only. dispatch-apply-patch.sh quotes it in cd "$cwd", and a bad directory blocks.
  • XSS Prevention: N/A.
  • Auth & Authorisation: Pass. No authentication code changes.
  • Data Protection: Pass. No new data store, no new log line, no PII.
  • API Security: N/A.
  • Gate & Trust-Chain Integrity: Pass. No gate is weakened. The cwd change moves the hook input toward the Claude Code contract, and it narrows two fail-open paths.
  • Supply chain: Pass. The lockfile holds 302 packages. All resolve from registry.npmjs.org, all carry an integrity hash, none has an install script, and none is a runtime dependency.

Gate integrity checks

The cwd change now feeds every gate, not only the patch route. I checked the hooks that read $PWD.

  • Merge gate. block-unreviewed-merge.sh:295-305 takes the repo root from the command and walks up for the ops root. A project under the fork still resolves the fork. A project outside the fork resolves nothing, and the marker lookup then fails closed. The crown-jewel gate does not weaken.
  • Edit gates. require-active-ticket.sh joins a relative target to the process directory (:749), and that is now the tree opencode writes into. This is the F1 fix.
  • State writers. No hook writes under a relative .claude path. A project-directory cwd therefore cannot place a marker in the wrong tree. Checked with a grep for relative .claude redirections.
  • Hook identity. The adapter runs each hook by absolute path under the resolved ops root. The cwd value cannot change which script runs. The settings wrapper's own $PWD walk-up appears only inside dispatch-apply-patch.sh, and it resolves the same anchor as the plugin.
  • Installer. The --name guard against rm -rf still holds. The version comparison uses digits only, so the version string cannot reach a shell.

Advisories

A1 MEDIUM (advisory): the new cwd argument is easy to drop silently. runGateHook declares callCwd after maxBufferBytes and timeoutMs (gate-dispatcher.ts:193-206). The one call site passes undefined, undefined, effectiveCwd (:363-372). A caller that supplies a buffer or timeout but forgets cwd gets the ops root again, which is the exact fault this delta fixes. Fix: take a single options object, or move callCwd ahead of the optional parameters.

A2 LOW: an id with an empty tail normalises to nothing. normaliseToolId returns toolId.split(".").pop(). For "edit." or "a.." that is "", and no wire matches "", so every gate misses with no message. No host emits such an id today. Fix: return the raw id when the tail is empty, and warn once on an unmatched id.

A3 LOW: an edit or write call with no target key still skips its gates. buildToolInput returns undefined when neither path nor filePath is a non-empty string, and the cascade continues (gate-dispatcher.ts:352-364). The patch route now fails closed on the same shape. dev behaved the same way for edit and write, so this is not a regression. Fix: mirror the patch behaviour, or warn once.

A4 LOW: the 1.3.4 floor evidence lives only in prose. The docs and the installer say that release binaries were run. Nothing in the tree records the probe. I corroborated the claim from upstream tags, so I accept it.

  • v1.3.4 reads the object entrypoint through readV1Plugin and isServerPlugin, and calls plugin.server(...) (packages/opencode/src/plugin/index.ts:76-78, :172-175).
  • v1.3.4's Hooks declares tool.execute.before and PluginInput declares directory (packages/plugin/src/index.ts:215, :26-33).
  • v1.3.3 calls every module export as a factory (packages/opencode/src/plugin/index.ts:128-131). That throws on the id string, which matches the reported fn3 is not a function.
    Fix: record the probe output, or mark the claim as source-inferred.

A5 INFO: CI did not run on the head SHA. All ten workflow runs for a2ddc9c report action_required, because the fork commits await approval. I ran these probes instead.

  • npm test in harness-adapters/opencode: 81 pass, 0 fail.
  • npx tsc --noEmit: no output.
  • bash test/smoke-install-layout.sh: PASS, and the installed shim still exports default only.
  • I did not run the repo-level docs lint or markdownlint.

Verdict

APPROVED. The blocking finding is closed, and I verified the fix against the upstream tool source rather than the PR's own comment. The advisories do not change the verdict.

Guard: an approval covers this head only. The merge still needs the human per-PR nod and a green CI run.


🛡️ Reviewed by Hakim (Security Auditor)
📌 Reviewed commit: a2ddc9cf84bd35e1c3acf34ca1164c0768977f43

@ahmed-bermawy ahmed-bermawy left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No blocking code finding. Approval is withheld only because no CI result exists for a2ddc9c. All ten workflows on the head SHA report action_required, so GitHub never ran them. Approve the pending runs, then a short confirmation pass can write the merge marker. The three blocking findings from the last review are closed, and the local suite passes.

Code Review: PR #1959

Commit: a2ddc9cf84bd35e1c3acf34ca1164c0768977f43
Scope: Delta re-review (5023735..a2ddc9c)

Summary

The delta closes the three blocking findings from the previous review.

  • runGateHook sends a top-level cwd and spawns the hook in that directory (src/gate-dispatcher.ts:201-208). registerGateDispatcher passes ctx.location.directory (:567-570). createV1Hooks passes input.directory (:647-649).
  • buildToolInput takes a generation argument and reads filePath only on V1 (src/derive-gates.ts:288-305).
  • A patch or apply_patch call with no envelope throws instead of skipping (src/gate-dispatcher.ts:353-363).
  • normaliseToolId makes the dispatcher match ids the way the wiring warning already did (:305-313, :414, :441).
  • The installer accepts a bare 1.17.16 and opencode v2.1.4, and warns below 1.3.4 (bin/install-opencode-adapter.sh:172-193).
  • AgDR-0092 gains an Update (#1956) section (docs/agdr/AgDR-0092-opencode-gate-adapter.md:54-66). A new test/installer-version.test.ts stubs the version command.

Prior findings at 5023735: B1 (wrong tree for relative targets) resolved and tested. B2 (dead version guard, wrong floor) resolved and tested. B3 (AgDR omission) resolved. Advisory A1, A2, A5 and A6 resolved.

Acceptance Criteria

Issue #1956 has no comments. Its criteria are as filed.

Issue Criterion Status Evidence
#1956 Depends on the V2 package. One default export serves both APIs: the V1 factory under server(), and the V2 entrypoint Met src/gate-dispatcher.ts:670-674 exports one literal object with id, setup and server. The literal replaces the Plugin.define spread, for the reason at :662-666. AgDR item 1 records the decision.
#1956 V2 registers through ctx.tool.hook("execute.before", ...) and throws when a gate exits 2 Met src/gate-dispatcher.ts:567-570, :376-380. Tests at test/gate-dispatcher.test.ts:188, :199.
#1956 shell maps to the bash gate set, bash keeps working, edit and write keep their mapping Met src/derive-gates.ts:160, :290-291. Tests at test/derive-gates.test.ts and test/gate-dispatcher.test.ts.
#1956 patch has a decided, tested behaviour. No undocumented silent skip Met Wire at src/gate-dispatcher.ts:520-542. Payload cwd at :207. No-envelope block at :353-363. Tests at test/gate-dispatcher.test.ts:444, :469.
#1956 Gate logic stays in gate-dispatcher.ts and derive-gates.ts. Transport only Met The full PR diff changes no file under .claude/ (0 paths).
#1956 V1 does not regress: the suite passes and a V1-shaped plugin still loads Met npm test 81/81. test/smoke-install-layout.sh reports HAS_V1_SERVER:true. Tests at :500, :520. The V1 live load on a real 1.x binary remains Inferred, as docs/opencode-adapter.md states.
#1956 New tests cover V2: allow, deny, ops-root resolution, session id, tool-id mapping Met Allow :199, deny :188, ops root :269, session id tests in test/session-id.test.ts, mapping test/derive-gates.test.ts:247.
#1956 The three docs state that V1 and V2 are both supported, and that V2 needs directory plugin paths Met harness-adapters/opencode/README.md:5, docs/harnesses/opencode.md:7-14, docs/opencode-adapter.md:5, :26.
#1956 The batch bypass note is removed Met Items 7 of the AgDR update withdraws it (docs/agdr/AgDR-0092-opencode-gate-adapter.md:64). The shipped docs no longer carry it.

Checklist Results

  • Architecture & Design: ✅ Pass. The transport stays thin, both generations share one cascade, and the delta adds no policy code.
  • Code Quality: ✅ Pass. event.input is narrowed before the cast (src/gate-dispatcher.ts:406-409). Fail-closed handling is unchanged.
  • Testing: ✅ Pass. New coverage at test/gate-dispatcher.test.ts:444, :469, :520, :539, plus test/installer-version.test.ts for both version shapes, the floor, one release below it, and a pre-1.0 major.
  • Security: ⚠ Not re-run. Hakim owns the OWASP and gate-integrity checklist. Advisory A1 below is a gate-integrity edge and belongs in both reviews.
  • Performance: ✅ Pass. Ops-root resolution and gate derivation still run once per plugin init. The extra payload field adds nothing per call.
  • PR Description & Glossary: ✅ Pass. The body describes the cwd payload, the block on an empty envelope, and the measured 1.3.4 floor. The glossary defines cwd, gate wire, and fail open. It links Closes #1956.
  • Summary Bullet Narrative: ✅ Pass. Each bullet states the change and why it matters.
  • Technical Decisions (AgDR): ✅ Pass. The Update (#1956) section records the dual literal export, the type-only devDependency, the hand-maintained patch wire, the dual vocabulary, the cwd payload, the measured floor, and the withdrawal of the batch note.
  • Adopter Handbooks: ✅ Pass. handbooks/language/typescript/strict-mode passes: the one new assertion follows a runtime check and the diff adds no bare any. handbooks/general/commit-message-quality passes: both delta subjects carry (#1956) and both bodies explain why. No loaded handbook sets ENFORCEMENT: blocking.
  • Fallow Static Analysis (JS/TS): N/A. The fallow CLI is not installed on this machine.

Issues Found

Advisory only. None of these changes the verdict.

A1 — advisory, gate integrity. harness-adapters/opencode/src/gate-dispatcher.ts:207-208.
Sending the session directory as cwd makes dispatch-apply-patch.sh change directory there (dispatch-apply-patch.sh:7-8). That script then runs the Edit/Write checks from .claude/settings.json as wrapper commands. Each wrapper resolves the ops root from its process working directory.
Observed: the Edit-matcher wrapper exits 0 when it runs in /tmp. The mechanism is [ -d "$r/.claude/hooks" ] || exit 0 in the walk.
So in the APEXYARD_OPS_ROOT mode, where the session directory sits outside the ops root, every inner check passes and a patch call writes ungoverned. An absolute target inside the ops fork passes too.
Basis: Observed for the wrapper exit code, Inferred for the end-to-end reachability. The pre-delta code ran those checks from the ops root, so this exposure is new.
It follows from the cwd fix both reviews asked for. The root cause is framework-side: no bash hook reads APEXYARD_OPS_ROOT, and the session pin needs CLAUDE_CODE_SESSION_ID, which the adapter unsets.
Suggested fix: record the limit in the README and the AgDR, or send cwd only when the session directory resolves to the ops root.

A2 — nit, documentation. docs/agdr/AgDR-0092-opencode-gate-adapter.md:62, docs/opencode-adapter.md:34.
Both lines list require-active-ticket.sh among the hooks that resolve relative targets from the payload cwd. That script reads no .cwd field. It resolves from its process working directory, which the adapter also sets.
Suggested fix: name dispatch-apply-patch.sh and block-protected-target-writes.sh as the .cwd readers, and describe the ticket gate as using the subprocess directory.

A3 — nit, documentation. docs/agdr/AgDR-0092-opencode-gate-adapter.md:56.
The lead sentence says three recorded decisions changed, then seven items follow. Restate the count, or say the section records the changes and the supporting decisions.

Suggestions

  • test/installer-version.test.ts:38 asserts only the absence of a warning for 1.17.16. A parse failure would also produce no warning. The warning cases at :55 do pin the parse, so the pair is adequate. A positive assertion for the prefixed form below the floor would make it direct.

Validation

Ran at a2ddc9c in /home/ahmed/my_work/apexyard-opencode-v2:

  • npm test in harness-adapters/opencode: 81 passed, 0 failed.
  • npm run typecheck: clean (tsc --noEmit, exit 0).
  • bash test/smoke-install-layout.sh: PASS. Export keys are ["default"]. The definition is an object with id: apexyard.gates, setup, and the V1 server(). execute.before is registered.
  • bash test/smoke-block-unreviewed-merge.sh: PASS, including the negative control.
  • node bin/docs-lint.mjs --strict: 0 findings.
  • Installer parse, run directly on both shapes: 1.17.16 → 1.17.16, opencode v2.1.4 → 2.1.4, 1.3.4 → 1.3.4, 1.3.3 → 1.3.3, 0.9.0 → 0.9.0.
  • Edit-matcher wrapper from the real .claude/settings.json, run with the working directory in /tmp: exit 0. This is the basis of A1.
  • git range-diff upstream/dev..5023735 upstream/dev..HEAD: commits 1 to 8 unchanged, two commits added (b4425b4, a2ddc9c). No history rewrite. 5023735 is an ancestor of the head.

CI, the blocking limit:

  • The workflow-run API for a2ddc9c lists 10 runs, all with conclusion action_required and no job entries. gh reports no checks on the branch. GitHub is holding these runs for maintainer approval.
  • CI for 5023735, the previously reviewed head, was green (8 runs, success).
  • No check-run result exists for the reviewed SHA, so I did not approve.

Other limits:

  • The 1.3.4 floor is the author's measurement on downloaded release binaries. I did not fetch them.
  • The live V2 run against opencode 2.1.4 is not reproduced here.
  • shellcheck and fallow are not installed, so the changed shell script is unchecked by them.
  • docs/harnesses/opencode.md was checked against the code and the test count (81). Its historical claims were not re-reviewed.

Verdict

COMMENT

The delta is correct. Every blocking finding from the previous round is closed with a test. Three advisories remain, and A1 is worth a documented decision rather than a code change in this PR.

Approval is withheld on one point only: CI has no result for a2ddc9c. Once the pending workflow runs are approved and green, a short confirmation pass can record the merge marker.


🤖 Reviewed by Rex (Code Reviewer Agent)
📌 Reviewed commit: a2ddc9cf84bd35e1c3acf34ca1164c0768977f43

…it paths

Rex A1: running the bash checks from a session directory outside the ops root
made them exit without enforcing in APEXYARD_OPS_ROOT mode. Fall back to the
ops root there, so a patch cannot run ungoverned. Hakim A3: an edit or write
with no target path now blocks instead of skipping every gate. Hakim A2: keep
the raw tool id when its dotted tail is empty. Correct the docs on which hook
reads `.cwd`, and the AgDR decision count.

Refs me2resh#1956
@ahmed-bermawy

Copy link
Copy Markdown
Author

Review-pair delta — 5e1106e

Hakim approved a2ddc9c. Rex found no blocking issue and withheld approval only because all 10 workflow runs at the head sit at action_required (maintainer approval needed). I took the substantive advisories.

  • Rex A1 (fail open outside the ops root) — the session directory is now used only when it sits inside the ops root. In APEXYARD_OPS_ROOT mode, both the payload cwd and the subprocess cwd fall back to the ops root, where the bash checks can find their own wiring. Two tests cover the inside and outside branches.
  • Hakim A3 / review pair — an edit or write with no target path now blocks instead of skipping every gate, mirroring the patch no-envelope path. Test added.
  • Hakim A2 — a dotted id with an empty tail keeps the raw id.
  • Rex A2 — the docs now say require-active-ticket.sh reads the subprocess working directory, not a .cwd field. The AgDR says "several decisions", not three.
  • Rex A4 — the installer tests assert warning presence on the below-floor bare form, and the prefixed form is covered for absence. The floor itself is still measured against release binaries; the probe steps are in the round-3 comment.

Declined (advisory only):

  • Hakim A1 (options object for runGateHook) — the trailing callCwd is only reached through the two handler builders, which both pass it explicitly. I would rather not churn the exported signature in this PR; it is a reasonable follow-up.
  • Hakim A4 (floor evidence prose) — the measurement is reproducible with the probe described in the round-3 comment. Adding a binary-download test to CI is out of scope for this PR.
  • Shellcheck and fallow are absent locally, as before.

At 5e1106e: npm test 84/84, npm run typecheck, both smoke scripts, node bin/docs-lint.mjs --strict (0 findings) and bin/run-pre-push-checks.sh pass. CI is still action_required until a maintainer approves the runs.

@me2resh me2resh left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

APPROVED. The delta fixes all three blockers from review 5780672099. It also takes both optional items. No regression to the earlier approved wiring was found. Next action: the per-PR human merge approval. Four advisory notes are below. None of them blocks.

Code Review: PR #1959

Commit: 5e1106e6818cf5af2b6d7cf92c332d0f9cce739c
Scope: Delta re-review, 5023735668aaebaf6517534689dbbef4e13de2ce..5e1106e6818cf5af2b6d7cf92c332d0f9cce739c

Summary

Prior posted reviews: Rex 5780672099 and Hakim 5780619795, both at 5023735. This round reviews four linear commits (b4425b4, a2ddc9c, 5cb4a50, 5e1106e). They change 10 files. The range has no merge commit and no rebase. Each commit's parent is the one before it.

The delta does these things:

  • It sends the session directory to the hooks as the payload cwd and as the subprocess working directory.
  • It fixes the installer version parse and moves the floor to 1.3.4.
  • It adds an Update (#1956) section to AgDR-0092.
  • V1 edit and write now read filePath only.
  • It blocks a patch call with no envelope, and an edit or write call with no target path.
  • It normalises dotted tool ids in the dispatcher.
  • It narrows event.input before the cast.

Acceptance criteria for #1956: the earlier table stands. The criterion "patch has a decided, tested behaviour" was Met with finding B1. It is now Met without a finding (see B1 below). The docs criterion is still Met, and the 1.x floor in the docs now has measured evidence (see B2). No criterion is Not met.

Checklist Results

  • Architecture & Design: ✅ Pass. One shared cascade still serves both generations. The generation and cwd fields are carried on the normalised call (src/gate-dispatcher.ts:286-298). No hook or settings file changes.
  • Code Quality: ✅ Pass. The installer guard can now fire (B2). The no-target paths throw a named reason (src/gate-dispatcher.ts:367-385).
  • Testing: ✅ Pass. New tests cover the payload and subprocess cwd for a relative patch target, the inside and outside fallback branches, the V1 decoy path, a dotted id, the no-envelope and no-path blocks, and five installer version shapes. The test count is 84 (5 + 27 + 10 + 39 + 3 over test/*.test.ts). This matches the 84 that docs/harnesses/opencode.md states.
  • Security: ✅ Pass. B1 is fixed for the default layout. The outside fallback does not fail open against dev (see advisory 1). I did not re-run Hakim's OWASP checklist.
  • Performance: ✅ Pass. No new hook runs. resolveHookCwd runs once at init.
  • PR Description & Glossary: ✅ Pass. The body states the 1.3.4 floor and the measurement, and it links the AgDR-0092 update. The glossary is present and updated for server().
  • Summary Bullet Narrative: ✅ Pass.
  • Technical Decisions (AgDR): ✅ Pass. See B3.
  • Adopter Handbooks: ✅ Pass. handbooks/language/typescript/strict-mode (A6) is resolved by the object check at src/gate-dispatcher.ts:429-431. The commit-message handbook passes: each delta commit has a body.
  • Fallow Static Analysis (JS/TS): N/A. The fallow CLI is not installed on the review machine.

Issues Found

Earlier blockers:

  • B1 (cwd): resolved. runGateHook now sends {tool_name, tool_input, cwd} and spawns the hook with that directory (src/gate-dispatcher.ts:196-208). V2 passes ctx.location.directory (:595). V1 passes input.directory (:679). Both go through resolveHookCwd. dispatch-apply-patch.sh:7-8 and block-protected-target-writes.sh:18 read .cwd first. require-active-ticket.sh:749 joins a relative target to $PWD, which is now the session directory. The test at test/gate-dispatcher.test.ts "a patch call sends the session directory as the payload cwd and hook working directory" uses the relative target src/a.ts under <ops-root>/workspace/project. It asserts both values. The fallback for a session directory outside the ops root uses the ops root for both values. That is the dev behaviour for edit and write, so it cannot fail open relative to dev (Inferred from the code. Not exercised.). See advisory 1.
  • B2 (installer guard): resolved. The new pattern at bin/install-opencode-adapter.sh:180 accepts any non-digit prefix. Observed: applying that pattern to the real local opencode --version output gives 1.17.16, and opencode v2.1.4 gives 2.1.4. The comparison at :188 warns below 1.3.4 and stays silent for 1.17.16. test/installer-version.test.ts covers bare, prefixed, at-floor, below-floor and pre-1.0 output. The 1.3.4 floor comes from the author's run of the release binaries (1.3.3 fails with fn3 is not a function, 1.3.4 loads the object). I did not reproduce it (Unverified). It is consistent with my earlier observation that 1.17.16 accepts the object entrypoint. The docs, the installer, the README, the smoke script and the PR body all say 1.3.4. No 1.18.29 reference remains in the changed files.
  • B3 (AgDR): resolved. docs/agdr/AgDR-0092-opencode-gate-adapter.md § "Update (#1956)" records each decision listed in B3: the literal dual export, the type-only devDependency swap, the hand-maintained patch wire, and the side-by-side vocabularies. It also records the cwd payload, the measured floor and the withdrawal of the batch note. The PR body links it.

Earlier advisories and optional items:

  • Optional V1 filePath only (Rex A1, Hakim A1): resolved at src/derive-gates.ts:304, with a test.
  • Optional empty-patch block (Rex A3): resolved at src/gate-dispatcher.ts:371-376, with a test.
  • Rex A2 and Hakim A2 (dotted ids): resolved by normaliseToolId at :311-314. The dispatcher and the warning now use the same rule. See advisory 2.
  • Rex A5 (comment wording): resolved at src/derive-gates.ts:277.
  • Rex A6 (cast): resolved.
  • Rex A4 (typecheck and smoke scripts not in CI): untouched. It is a gap that dev already has.
  • Hakim A3 (walk-up trust): untouched. It is a gap that dev already has.

Regression check against the approved wiring: the delta does not touch CLAUDE_MATCHER_TO_OPENCODE_TOOLS, so every Bash wire still maps to shell and bash. Edit and write still map to edit and write. The exec error, timeout and buffer paths in execGateHookReal and runGateHook are unchanged. Only the payload and the cwd argument changed.

The Bash gates now run from the session directory instead of the ops root. opencode's shell runs commands in that directory, and the hooks find the ops root by walking up from it (require-active-ticket.sh:414-416, :636). The git context they read now matches the tree the command acts on. Inferred, not exercised.

New blocking findings: None.

Suggestions

  1. Fallback scope (advisory). When the session directory is outside the ops root (APEXYARD_OPS_ROOT mode), a relative patch or edit target is still judged against the ops root. This is the B1 condition, now limited to that mode. It is the dev behaviour. A plain relative target under that root is judged as an ops-fork file, which is the stricter tree. resolveHookCwd compares paths lexically, so a symlinked or differently cased session path also takes the fallback. Suggestion: add one sentence to the docs stating that relative targets resolve against the ops root in this mode, and that absolute targets are preferred there.
  2. Dotted-id normalisation reach (advisory). normaliseToolId keeps the last dotted segment of any id. A custom tool whose id ends in .edit, .write or .shell is now gated as that built-in. With no path, the new no-target throw blocks it. This is a fail-closed false block, not a fail-open. Opencode's own plugin and MCP ids appear to use underscores (Unverified).
  3. Floor evidence (advisory). The 1.3.4 floor rests on the author's measurement, which this review did not reproduce. If a user reports a load failure, re-check the floor first.
  4. nit: test/installer-version.test.ts creates a temp directory for each case and does not remove it.

Validation

Current checks in this round:

  • I read the delta diff for all 10 files. I read dispatch-apply-patch.sh:1-40, block-protected-target-writes.sh:18, and require-active-ticket.sh:400-440 and :625-680 at the head.
  • Counting ^test( over test/*.test.ts gives 84.
  • I applied the new sed pattern to the real opencode --version output (1.17.16) and to opencode v2.1.4. Both parsed.

CI, read once: opencode unit tests passed (run 42005439232). So did hook tests (linux jq-1.7), hook test suite, markdownlint-cli2, records-lint, check, tier, Verify Ticket ID, CodeQL, Analyze and report. conformance and nightly-failure were skipped. The gh pr checks output does not show the head SHA for each run. The PR head reports 5e1106e.

Limits: I did not run the adapter tests, the smoke scripts or the installer, because that would execute contributor code. CI ran npm test. The author reports local typecheck and smoke results, which I did not reproduce. The prior findings come from the posted bodies of 5780672099 and 5780619795.

Verdict

APPROVED
B1, B2 and B3 are fixed, and the fixes have tests. The outside fallback is no weaker than dev. No regression or new correctness fault was found. The four suggestions are advisory. Next action: per-PR human approval before merge.


🤖 Reviewed by Rex (Code Reviewer Agent)
📌 Reviewed commit: 5e1106e6818cf5af2b6d7cf92c332d0f9cce739c

@me2resh me2resh left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Security Review: PR #1959

Commit: 5e1106e6818cf5af2b6d7cf92c332d0f9cce739c
Scope: Delta re-review. Range 5023735..5e1106e (four commits). Baseline is my review at 5023735 (CHANGES REQUESTED, one HIGH, advisories A1 to A3).

Summary

The delta sends the session directory as the hook payload cwd and as the hook subprocess working directory. It uses the session directory only when that directory is inside the ops root. Outside the ops root, both values fall back to the ops root. An edit or write call with no target path now blocks. The V1 handler reads filePath only. The dispatcher and the wiring warning now share one tool-id normalisation. The installer version parse accepts both real output shapes. The delta adds no eval and no unquoted shell use. The installer change uses a fixed sed pattern, and a case check rejects any non-digit value before the arithmetic.

CI for the head SHA is green (one read).

Prior findings

Finding Status Evidence
HIGH: relative targets judged against the ops root Resolved for the documented layout src/gate-dispatcher.ts:207, :323, :356. The plugin is installed in workspace/<project> inside the ops fork, so the session directory is inside the ops root and becomes the cwd. Tests at test/gate-dispatcher.test.ts cover the inside branch, the outside branch, and the patch payload cwd. See advisory 1 for the split-portfolio residual.
A1: decoy path next to filePath on V1 Resolved src/derive-gates.ts:304 reads filePath only on V1. A test with a decoy path was added.
A2: tool-id normalisation differs Resolved src/gate-dispatcher.ts:311. The dispatcher and warnOnWiringGaps both call normaliseToolId. An empty tail keeps the raw id, which matches no gate and no route.
A3: ops-root walk trusts the first anchor Open, not changed by this PR Advisory 2 describes a related new exposure.

Checklist Results

  • Secrets & Credentials: Pass
  • Injection Prevention: Pass. The hook spawn is unchanged (argument vector, payload on stdin). The new cwd value comes from the host location at plugin load. It does not come from tool arguments.
  • XSS Prevention: N/A
  • Auth & Authorisation: Pass for the documented layout. See advisory 1.
  • Data Protection: Pass
  • API Security: N/A
  • Gate & Trust-Chain Integrity: Pass, with advisories 1 and 2. The new empty-target block closes a silent skip. The delta does not add a fail-open path on the documented layout.

Security Issues Found

No blocking issue.

Recommendations (advisory, non-blocking)

1. MEDIUM: in split-portfolio mode with APEXYARD_OPS_ROOT, the original wrong-tree fault returns.

In the split-portfolio v2 layout, workspace/ is in the sibling portfolio repository, outside the ops fork. There are two cases.

  • Without the override, the walk-up from <portfolio>/workspace/<project> stops at the portfolio repository. That repository holds both onboarding.yaml and apexyard.projects.yaml, which is the legacy anchor. A standard portfolio repository has no .claude/settings.json, so deriveGatesFromOpsRoot returns an empty gate table and every call is allowed with no warning. This is the same on 5023735 and on dev. It is not caused by this delta.
  • With APEXYARD_OPS_ROOT set to the fork, the session directory is outside the ops root. resolveHookCwd then returns the ops root, so a plain relative patch target such as src/a.ts is judged as <fork>/src/a.ts. The ticket gate reads the fork's marker, not the project's marker. This is the fault from my previous review, for this configuration only.

It does not block because the adapter docs do not describe a split-portfolio install, and the fallback is safer than the alternative. Rex observed that the Edit-matcher wrapper exits 0 when it runs outside the ops root. The fallback is also no regression against dev. The docs say that both values fall back to the ops root. They do not say that relative targets are then judged against the wrong tree.

Proposed fix: when the session directory is outside the ops root, resolve relative targets against the session directory in the adapter before dispatch. For edit and write, resolve file_path to an absolute path. For patch, rewrite the relative Add File, Update File, Delete File and Move to paths to absolute paths, or block a patch with a relative target. Keep the subprocess and payload cwd at the ops root in that mode. At minimum, state the residual in the README and the AgDR. Also state that split-portfolio mode without the override runs no gates.

2. MEDIUM: the session-directory cwd lets files in the managed repository choose which hook code runs.

Before this delta, every hook ran from the ops root. It now runs from workspace/<project>. Two paths read hook code relative to that directory.

  • dispatch-apply-patch.sh changes to the payload cwd. It then runs the Edit/Write wrapper commands from .claude/settings.json. The opencode adapter unsets CLAUDE_CODE_SESSION_ID, so the wrappers skip the session pin and walk up from $PWD. Their anchor is .apexyard-fork or onboarding.yaml, together with a .claude/hooks directory. The adapter's own anchor is stricter: .apexyard-fork, or both YAML files. If a project has onboarding.yaml and .claude/hooks/ but no apexyard.projects.yaml, the adapter resolves the real fork. The patch-route wrappers then run the project's own hook scripts. Those scripts run as code, and an exit 0 from them passes the check.
  • require-agdr-for-arch-pr.sh:176 and :397, and validate-issue-structure.sh:115 and :121, compute REPO_ROOT from git rev-parse --show-toplevel in $PWD. They then source $REPO_ROOT/.claude/hooks/_lib-read-config.sh. From workspace/<project>, this sources the project's file on a gh pr create or gh issue create call.

It does not block because opencode already loads project plugins from that same directory. The adapter itself installs at <project>/.opencode/plugins/apexyard/. A repository that can plant these files can therefore already run code in the session. The second path also exists under Claude Code when the session working directory is inside a clone. The anchor gap in the first path is Inferred from reading the wrapper and the resolver. I did not run it.

Proposed fix: the adapter-side fix in advisory 1 (keep the subprocess cwd at the ops root and send absolute targets) also closes both paths for opencode. On the framework side, the two hooks can source their libraries from their own hook directory, as most hooks already do.

3. LOW: resolveHookCwd compares paths lexically.

src/gate-dispatcher.ts:323 uses path.relative without resolving symlinks. Suppose APEXYARD_OPS_ROOT uses one spelling of a path (for example /tmp/...) and opencode reports the physical spelling (/private/tmp/...). The session directory is then classed as outside, and advisory 1 applies. Proposed fix: compare realpathSync values, and fall back to the lexical check when a path does not exist.

Verdict

APPROVED

The HIGH is fixed for the documented layout, with tests. A1 and A2 are resolved. The delta adds no fail-open on the documented layout, no eval, and no unquoted shell use. Advisories 1 and 2 are follow-up work for split-portfolio support and for the trust model of the managed repository. They do not block this PR.

Limits: I read the code and did not run contributor code. Advisory 2 rests on reading the hooks and the resolver, not on a live run. This verdict is judged against my review at 5023735 only.


🛡️ Reviewed by Hakim (Security Auditor)
📌 Reviewed commit: 5e1106e6818cf5af2b6d7cf92c332d0f9cce739c

@atlas-apex
atlas-apex merged commit 90c5e06 into me2resh:dev Nov 5, 2026
14 checks passed
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.

3 participants