Repository navigation
feat(#1956): port the opencode gate adapter to the V2 plugin API - #1959
atlas-apex merged 14 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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
registerGateDispatchernarrows the opencode context to a structuralGateDispatcherContextinstead of importing the whole server client. - Code Quality: ✅ Pass. The tool-input boundary stays
unknownand is narrowed defensively. Fail-closed execution (runGateHook) is unchanged. No bareanyin 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
patchcoverage 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.mdpasses:tsconfig.jsonsetsstrict: trueand the diff adds no bareany.handbooks/general/commit-message-quality.mdraises one nit, below. - Fallow Static Analysis (JS/TS): N/A. The
fallowCLI 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:55says the API surface was verified against "the real, installed@opencode/[email protected]".package-lock.jsonresolves 2.2.5, andnpm run typecheckran against 2.2.5. I compared the 2.1.4 and 2.2.5plugin.d.tsandtool.d.tsfor the surface this adapter uses and found no difference, so the floor^2.1.4is 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 exportsAPEXYARD_SESSION_ID, or narrow the claim. src/gate-dispatcher.ts:302adds asessionId !== undefinedguard before the command rewrite. No test drives a handler event withoutsessionID;test/session-id.test.ts:112exercisesexecGateHookRealdirectly instead. Add a handler-level test for the no-session path.- The V1 compatibility path has no handler-level test.
bashandfilePathare proven only at thebuildToolInputandgateMatchesToolCalllevel. 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:136andbin/install-opencode-adapter.sh:170tell readers to pin "adapter 0.1.0". The package isprivate: trueand 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.mdrequires (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 testinharness-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 withid: apexyard.gates, andsetupregisteredexecute.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.jsonwiring to name the gates thatpatchwould bypass.
Verification limits:
gh pr checks 1959reports "no checks reported on the 'feature/1956-opencode-v2-plugin-adapter' branch", although.github/workflows/adapter-tests.ymlmatchesharness-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
opencodeon PATH here isv2.1.4, which is consistent with the claim. shellcheckis not installed here, so the changed shell scripts are unchecked.docs/harnesses/opencode.mdwas 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.
Review response —
|
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.
Delta response —
|
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.
|
Final delta at
One advisory declined:
|
|
@ahmed-bermawy thank you for #1959. The V2 port is clear, and routing the CI is green except one job: docs lint finds 29 writing faults in 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.
|
Fixed in
No claim changed; wording only. The CI runs on |
me2resh
left a comment
There was a problem hiding this comment.
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
execFileSyncwith an argument vector. TheAPEXYARD_SESSION_IDexport uses POSIX single-quote escaping. Theunset CLAUDE_CODE_SESSION_IDline 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 theshell/bashpath. Thepatchroute closes a real hole, because ondeva V2patchcall matched no gate at all. - Supply chain: Pass.
package-lock.jsonhas 302 packages. All of them resolve fromregistry.npmjs.org, all have anintegrityhash, none has an install script, and all aredev.@opencode/pluginis imported withimport typeonly, so nothing from it runs at runtime. - Installer: Pass.
bin/install-opencode-adapter.shdownloads nothing. It copies five local files into<target>/<name>/with default permissions. The new--nameguard stopsrm -rfon the target dir itself. It runsopencode --versiononly whenopencodeis 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, anddispatch-apply-patch.shreads 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
left a comment
There was a problem hiding this comment.
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_GATErows indispatch-bash.shuse theBashmatcher. Each row now yields ashellwire and abashwire (src/derive-gates.ts:227-229). - The settings.json Edit/Write rows keep
editandwrite(src/derive-gates.ts:161-163). - The V1 handler still reads
output.argsand thebashid. 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
cwdfield (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
shellorbashcall 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 #1956links 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-modeapplies (A6).handbooks/general/commit-message-qualityapplies and passes: every commit has a body. The privategeneral/org-engineering-handbookapplies 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
fallowCLI 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.
runGateHooksends{ tool_name, tool_input }with nocwd(src/gate-dispatcher.ts:200).- The hook runs with
cwd: opsRoot(src/gate-dispatcher.ts:201,:140). dispatch-apply-patch.sh:7-8changes to.cwd, then.tool_input.cwd, thenCLAUDE_PROJECT_DIR, thenPWD. Under the adapter, onlyPWDis set, and it is the ops root.require-active-ticket.shresolves a relativefile_pathagainst 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.16gave an emptyOC_VERSION. The guard then says nothing, at:178. - The form
opencode v1.17.16parses as1.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/plugin2.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 (
setupplusserver), written as a literal instead ofPlugin.define. - Both tool-id and argument vocabularies are carried side by side instead of probing the version. AgDR-0092 line 28 records
bashandfilePathas 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:365casts(event.input ?? {}) as Record<string, unknown>with no runtime object check. A non-objectinputfrom the host would pass the cast. Add atypeof === "object" && !== nullguard first. (A6)
Suggestions
- A1 (concur with Hakim A1).
buildToolInputreadsraw.path ?? raw.filePathfor both generations (src/derive-gates.ts:299). The V1 tools writefilePath, anddevread onlyfilePath. A V1 call that carries both keys gets checked onpath. Suggestion: let the V1 handler preferfilePath, or block when both keys are present and differ. - A2 (concur with Hakim A2).
warnOnWiringGapsstrips a dotted prefix (src/gate-dispatcher.ts:551). The dispatcher matchesevent.toolexactly. Normalise the id the same way in both places. - A3. A
patchcall 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 anapply_patchcall withoutpatchText(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 ciandnpm testonly (.github/workflows/adapter-tests.yml).npm run typecheckand 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 ondevtoo. - A5. The
derive-gates.ts:277comment says "V1 usedfilePathandbash".bashis a tool id, not an argument name. Reword it. - Positive note: the 1.17.16 binary already registers an
apply_patchtool withpatchText. Ondev, 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 testspassed (18 s), and so didhook tests (linux jq-1.7),markdownlint-cli2andrecords-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 runnode --test test/*.test.tsandbash test/smoke-install-layout.shin a scratch export, and run the installer with a stubopencodethat prints a bare1.17.16. - Observed: the installer's
sedexpression gives an empty result on the real localopencode --versionoutput (1.17.16). It parsesopencode v1.17.16. - Inspected: the local opencode 1.17.16 bundle shows the object-entrypoint check (
idplusserver()), thetool.execute.beforetrigger with{ tool, sessionID, callID }and{ args }, and theapply_patchtool'spatchTextargument with the*** Begin Patchenvelope. - Lock file, checked with a script over
package-lock.json: 302 packages. All resolve fromregistry.npmjs.org, all have anintegrityhash, and none hashasInstallScript. Every package is a dev dependency. Nozodentry remains.@opencode/pluginresolves to 2.2.5. - Not verified: the V2 facts (
shell,path,patchwithpatchText, the{ tool, sessionID, input }event,ctx.location.directory). The@opencode/pluginpackage 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
|
@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 Three changes are necessary before we can merge:
Two small fixes are optional but welcome in the same push:
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
Round 3 delta —
|
ahmed-bermawy
left a comment
There was a problem hiding this comment.
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 newcallCwdparameter 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— anedit/writecall 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
cwdisctx.location.directoryon V2 andinput.directoryon V1. The hook subprocess runs there. I checked the claim against the upstream sources and not against the comment.- opencode
v2.1.4resolves patch hunk paths withFileAccess.resolvePath(location.directory, hunk.path)(packages/core/src/tool/plugin/patch.ts:90). - The plugin
Context.locationisLocation.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:444asserts both the payload field and the subprocess directory.
- opencode
- A1 (MEDIUM, decoy
pathon V1) — CLOSED.buildToolInputtakes the generation and readsraw.filePathonly on V1. Test attest/gate-dispatcher.test.ts:520. - A2 (MEDIUM, normalisation mismatch) — CLOSED for the mismatch.
normaliseToolIdnow 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 newcwdtravels as JSON and is used in quoted positions only.dispatch-apply-patch.shquotes it incd "$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
cwdchange 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 anintegrityhash, 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-305takes 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.shjoins 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
.claudepath. A project-directorycwdtherefore cannot place a marker in the wrong tree. Checked with a grep for relative.clauderedirections. - Hook identity. The adapter runs each hook by absolute path under the resolved ops root. The
cwdvalue cannot change which script runs. The settings wrapper's own$PWDwalk-up appears only insidedispatch-apply-patch.sh, and it resolves the same anchor as the plugin. - Installer. The
--nameguard againstrm -rfstill 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.4reads the object entrypoint throughreadV1PluginandisServerPlugin, and callsplugin.server(...)(packages/opencode/src/plugin/index.ts:76-78,:172-175).v1.3.4'sHooksdeclarestool.execute.beforeandPluginInputdeclaresdirectory(packages/plugin/src/index.ts:215,:26-33).v1.3.3calls every module export as a factory (packages/opencode/src/plugin/index.ts:128-131). That throws on theidstring, which matches the reportedfn3 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 testinharness-adapters/opencode: 81 pass, 0 fail.npx tsc --noEmit: no output.bash test/smoke-install-layout.sh: PASS, and the installed shim still exportsdefaultonly.- 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
left a comment
There was a problem hiding this comment.
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.
runGateHooksends a top-levelcwdand spawns the hook in that directory (src/gate-dispatcher.ts:201-208).registerGateDispatcherpassesctx.location.directory(:567-570).createV1Hookspassesinput.directory(:647-649).buildToolInputtakes agenerationargument and readsfilePathonly on V1 (src/derive-gates.ts:288-305).- A
patchorapply_patchcall with no envelope throws instead of skipping (src/gate-dispatcher.ts:353-363). normaliseToolIdmakes the dispatcher match ids the way the wiring warning already did (:305-313,:414,:441).- The installer accepts a bare
1.17.16andopencode 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 newtest/installer-version.test.tsstubs 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.inputis 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, plustest/installer-version.test.tsfor 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
cwdpayload, the block on an empty envelope, and the measured 1.3.4 floor. The glossary definescwd, gate wire, and fail open. It linksCloses #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, thecwdpayload, the measured floor, and the withdrawal of the batch note. - Adopter Handbooks: ✅ Pass.
handbooks/language/typescript/strict-modepasses: the one new assertion follows a runtime check and the diff adds no bareany.handbooks/general/commit-message-qualitypasses: both delta subjects carry(#1956)and both bodies explain why. No loaded handbook setsENFORCEMENT: blocking. - Fallow Static Analysis (JS/TS): N/A. The
fallowCLI 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:38asserts only the absence of a warning for1.17.16. A parse failure would also produce no warning. The warning cases at:55do 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 testinharness-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 withid: apexyard.gates,setup, and the V1server().execute.beforeis 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.5023735is an ancestor of the head.
CI, the blocking limit:
- The workflow-run API for
a2ddc9clists 10 runs, all with conclusionaction_requiredand no job entries.ghreports 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.
shellcheckandfalloware not installed, so the changed shell script is unchecked by them.docs/harnesses/opencode.mdwas 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
Review-pair delta —
|
me2resh
left a comment
There was a problem hiding this comment.
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
cwdand 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
filePathonly. - 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.inputbefore 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
generationandcwdfields 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
cwdfor a relative patch target, the inside and outside fallback branches, the V1 decoypath, 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 overtest/*.test.ts). This matches the 84 thatdocs/harnesses/opencode.mdstates. - 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.
resolveHookCwdruns 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 atsrc/gate-dispatcher.ts:429-431. The commit-message handbook passes: each delta commit has a body. - Fallow Static Analysis (JS/TS): N/A. The
fallowCLI is not installed on the review machine.
Issues Found
Earlier blockers:
- B1 (cwd): resolved.
runGateHooknow sends{tool_name, tool_input, cwd}and spawns the hook with that directory (src/gate-dispatcher.ts:196-208). V2 passesctx.location.directory(:595). V1 passesinput.directory(:679). Both go throughresolveHookCwd.dispatch-apply-patch.sh:7-8andblock-protected-target-writes.sh:18read.cwdfirst.require-active-ticket.sh:749joins a relative target to$PWD, which is now the session directory. The test attest/gate-dispatcher.test.ts"a patch call sends the session directory as the payload cwd and hook working directory" uses the relative targetsrc/a.tsunder<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 thedevbehaviour for edit and write, so it cannot fail open relative todev(Inferred from the code. Not exercised.). See advisory 1. - B2 (installer guard): resolved. The new pattern at
bin/install-opencode-adapter.sh:180accepts any non-digit prefix. Observed: applying that pattern to the real localopencode --versionoutput gives1.17.16, andopencode v2.1.4gives2.1.4. The comparison at:188warns below 1.3.4 and stays silent for 1.17.16.test/installer-version.test.tscovers 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 withfn3 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 thecwdpayload, the measured floor and the withdrawal of thebatchnote. The PR body links it.
Earlier advisories and optional items:
- Optional V1
filePathonly (Rex A1, Hakim A1): resolved atsrc/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
normaliseToolIdat: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
devalready has. - Hakim A3 (walk-up trust): untouched. It is a gap that
devalready 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
- Fallback scope (advisory). When the session directory is outside the ops root (
APEXYARD_OPS_ROOTmode), 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 thedevbehaviour. A plain relative target under that root is judged as an ops-fork file, which is the stricter tree.resolveHookCwdcompares 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. - Dotted-id normalisation reach (advisory).
normaliseToolIdkeeps the last dotted segment of any id. A custom tool whose id ends in.edit,.writeor.shellis now gated as that built-in. With nopath, 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). - 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.
- nit:
test/installer-version.test.tscreates 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, andrequire-active-ticket.sh:400-440and:625-680at the head. - Counting
^test(overtest/*.test.tsgives 84. - I applied the new
sedpattern to the realopencode --versionoutput (1.17.16) and toopencode 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
left a comment
There was a problem hiding this comment.
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
cwdvalue 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 bothonboarding.yamlandapexyard.projects.yaml, which is the legacy anchor. A standard portfolio repository has no.claude/settings.json, soderiveGatesFromOpsRootreturns an empty gate table and every call is allowed with no warning. This is the same on5023735and ondev. It is not caused by this delta. - With
APEXYARD_OPS_ROOTset to the fork, the session directory is outside the ops root.resolveHookCwdthen returns the ops root, so a plain relative patch target such assrc/a.tsis 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.shchanges to the payloadcwd. It then runs the Edit/Write wrapper commands from.claude/settings.json. The opencode adapter unsetsCLAUDE_CODE_SESSION_ID, so the wrappers skip the session pin and walk up from$PWD. Their anchor is.apexyard-forkoronboarding.yaml, together with a.claude/hooksdirectory. The adapter's own anchor is stricter:.apexyard-fork, or both YAML files. If a project hasonboarding.yamland.claude/hooks/but noapexyard.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:176and:397, andvalidate-issue-structure.sh:115and:121, computeREPO_ROOTfromgit rev-parse --show-toplevelin$PWD. They then source$REPO_ROOT/.claude/hooks/_lib-read-config.sh. Fromworkspace/<project>, this sources the project's file on agh pr createorgh issue createcall.
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
Summary
{ id: "apexyard.gates", setup, server }. opencode 2.x callssetup(ctx). opencode 1.3.4+ callsserver().{ tool, input }event. The ops root resolves fromctx.location.directory.cwd:ctx.location.directoryon V2 andinput.directoryon V1. The hook subprocess also runs there. A relative patch or edit target underworkspace/<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_ROOTmode), both fall back to the ops root, where the bash checks can find their own wiring and cannot fail open.bashtoshell, and the edit/write file argumentfilePathtopath. The adapter accepts both. On V1 it readsfilePathonly, so a decoypathcannot 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.patchtool that writes files under theeditpermission. It routes to.claude/hooks/dispatch-apply-patch.sh, which re-runs every Edit/Write check once per target path. Before this wire existed, apatchcall ran no gate at all. Apatchcall with no envelope is now blocked rather than skipped.unset CLAUDE_CODE_SESSION_IDprefix is always emitted. Theexport APEXYARD_SESSION_ID=line is emitted only when an id is known.test/smoke-install-layout.shasserts the V2 definition shape, the V1server()member, and the registeredexecute.beforehook.bin/install-opencode-adapter.shnow parses both real version shapes (1.17.16andopencode v2.1.4). It warns only below 1.3.4, the first release verified to load an object entrypoint. The previous pattern required anopencodeprefix, so it never fired on a real 1.x install.@opencode/pluginis a devDependency. It is imported type-only, so the installed plugin needs no runtime dependency.Update (#1956)section of AgDR-0092.Testing
cd harness-adapters/opencode && npm testpasses, 84/84. That covers patch routing, patchcwd, the ops-root fallback, the no-envelope and no-path blocks, the dual entrypoint, V1filePath-only, the no-session prefix, a subagent-shaped event, the host-tool wiring warning, and the installer's version guard.npm run typecheckis clean against the installed@opencode/plugin(2.2.5 resolved from the^2.1.4floor).bash test/smoke-install-layout.shpasses. The installed layout exports onlydefault. That default is a{ id, setup }definition, exposesserver(), and itssetupregistersexecute.before.bash test/smoke-block-unreviewed-merge.shpasses, with the negative control.fn3 is not a function. 1.3.4 loads the object and callsserver(). 1.15.0, 1.16.0, 1.17.0, and 1.17.16 also load it.gh pr merge 999999 --repo <nonexistent-fixture> --squashwas refused by the unmodifiedblock-unreviewed-merge.sh. The whole chain ran: loader,setup, a realshellevent, the derived wires,event.input.command, then the hook.bash bin/run-pre-push-checks.shpasses.Glossary
{ id, setup }object opencode 2.x validates.idis stable, and it also scopes plugin storage.server()setup(). It returns the V1 hooks map.execute.before"tool.execute.before".(tool, command glob)pair from.claude/settings.json. A gate fires only when a wire matches the call's tool id.cwdpatchcall atdispatch-apply-patch.sh.settings.jsoncannot express it, because Claude Code and Codex reachapply_patchthrough a flag this adapter cannot pass.Closes #1956