Repository navigation
fix(hooks): judge OpenCode against its own permission rules - #4349
Conversation
|
Adrien EPPLING seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
📊 Automated PR Analysis
SummaryReplaces the OpenCode command hook's approach of rewriting commands to satisfy ~/.claude permission rules with one that evaluates commands directly against OpenCode's own permission config (reimplementing its last-match-wins evaluator), then reports allow/ask/deny back to OpenCode via a new permission.ask handler. This moves opencode from ViaRewrite(ApprovalOwner::Rtk) to InProcess(Host::OpenCode), aligning it with the other native-hook hosts. Review Checklist
Linked issues: #4195, #1213, #1155 Analyzed automatically by wshm · This is an automated analysis, not a human review. |
KuSh
left a comment
There was a problem hiding this comment.
Round 1 of 3 — CHANGES REQUESTED
Claim: rtk hook opencode judges a bash command against OpenCode's own permission rules, and the plugin hands that verdict back to OpenCode through permission.ask, so a rewrite never changes what the user's rules decide (#4195).
Scope: accept if narrowed to "the OpenCode plugin judges the command against OpenCode's own root and project rules, and skips the rewrite whenever the rewrite would change that verdict". Cut: the status/permission.ask channel (no OpenCode release calls that hook, see 1) and agent-scoped rules (#4195 stays open for them). #4306 is an alternative fix for the same issue, so only one of the two should land. #4187 rewrites the same plugin body and lands after this one. #4331 moves crate::discover::lexer, so whichever lands second updates the import. (round 1, frozen)
Ran: OpenCode 1.1.4, 1.3.4 and 1.18.34 (plus a load check on 2.0.22), develop plugin vs PR plugin, 9 policies. A mock model issues one real bash call per session. Also ran rtk hook opencode on the PR table's 14 rows and rtk hook check --agent opencode, and counted permission.ask trigger sites in 6 OpenCode binaries (1.1.4 to 2.0.22): 0. Invariants: every decision (allow, ask, deny, no rule), under the hook (the plugin is the hook), merge onto develop. Savings: n/a (no filter change).
Pins: 33 decisions, 33 mutated, 19 survived (2 of them equivalent; the 17 that matter are listed in 3)
Matrix: never run before, executed this round; pin check: never run before, executed this round
CI @48b15a9a: all green (15/15), CLA pending (the commits' author email is not linked to a GitHub account). Merged onto develop 4f9015f: cargo test fails 1 (see 2).
Blocks merge (3, frozen at round 1)
-
hooks/opencode/rtk.ts:55: the status channel is inert, so #4195's allowed command is still blocked. OpenCode never calls apermission.askplugin hook: the binaries of 1.1.4, 1.3.4, 1.4.17, 1.14.41 and 1.18.34 contain no trigger site for it (their triggers aretool.execute.before,chat.params,shell.env, …). Evidence on 1.18.34, 1.3.4 and 1.1.4 with{"*": "deny", "git status": "allow"}:- The PR plugin receives
{"command":"rtk git status","status":"allow"}. - OpenCode logs
evaluated permission=bash pattern="rtk git status" … action.pattern=* action.action=denyand the tool returns the rule error, the same as develop. - An instrumented copy of the plugin recorded no
permission.askcall. It recorded none on anaskeither.
With
{"git push *": "ask"}the PR answersstatus: "ask", and OpenCode runsrtk git push origin mainwithout prompting (develop does the same). On 2.0.22 neither plugin loads (Missing key default, pre-existing, #4187). What has to change is the fallback the PR body names, widened from "would downgrade" to "would change": when the rewrite's verdict differs from the typed command's verdict, return no rewrite. An ask turning into "no rule" is an upgrade to a silent run, and that is the ask bypass above. Rows a correct fix passes on 1.18.34:- the #4195 policy runs the typed
git status - the reporter's policy at root level (
"*": "deny", "npx ts-node*task-cli*": "allow") runs the typednpx … git push *: askprompts forgit push origin main*: allowwithgit push *: denystill denies- no rules,
*: askand*: allowstill rewrite tortk …
permission.ask, the pending map andstatusthen go. - The PR plugin receives
-
src/hooks/permissions_opencode.rs:170: the merge onto develop fails the test-isolation guards (#3758 landed after this branch's base).only_user_dirs_resolves_user_locationsflagsdirs::home_dir,env::current_dirandvar_os("OPENCODE_CONFIG"). Once those go throughuser_dirs::home,user_dirs::current_diranduser_dirs::env_path,every_variable_rtk_reads_is_redirected_for_a_childasks forOPENCODE_CONFIGinINHERITED_VARS(src/core/test_isolation/scratch.rs). Evidence: the merge with 4f9015f gives 3859 passed, 1 failed. -
src/hooks/hook_cmd.rs:1200: unpinned behaviour. These mutations all pass the suite:opencode_answer(no test at all): dropping the empty-command guard;{}from the Deny arm; a rewrite from the Defer arm; swapping the arguments ofopencode_status(verdict, after);Noneinstead ofagentin the second lookup.- Routing:
opencodeback toViaRewrite, or skipping the OpenCode branch ofcheck_command_for_agent. Either way the plugin andrtk hook check --agent opencodecan drift apart unnoticed. - Segment aggregation: the unattestable check moved above deny;
Defaultreturned for an ask; empty segments kept. - Config discovery: no project config; no agent block; agent block before root;
OPENCODE_CONFIGadded on top of global;.jsoncbefore.json; project before global. Nothing callsopencode_configsorload_opencode_rulesin a test. After the rebase,user_dirs's test scratch makes that possible.
Tests must cover:
opencode_answerafter the change in 1: the rows in 1, as rules in and answer JSON out.- Discovery: a project
opencode.jsonfound from a subdirectory; global before project, with the project rule winning;OPENCODE_CONFIG. - The agent block after root, or drop
--agentand the agent block until the plugin has an agent to pass.
Each mutation above must turn one of them red.
Optional — will not hold merge
src/hooks/hook_cmd.rs:1194:/// Run the Factory Droid PreToolUse hook natively.now documentsrun_opencode, andrun_droidlost its doc.opencode_answerreloads every config file twice per call. Load the rules once and evaluate both commands against them.!rules.is_empty() &&incheck_command_with_opencode_rulesis redundant: with no rules, every action isNone.- Several places still say the plugin calls
rtk rewrite:hooks/opencode/README.md:8, thedecision.rsmodule table and its identity-rewrite note (decision.rs:195), and the plugin's catch comment. The plugin's "Requires: rtk >= 0.23.0" is stale too: an older rtk on PATH now silently stops rewriting.
Filed as follow-ups
- #4422 OpenCode permission rules: rtk's config loader diverges from OpenCode's config resolution — outside the narrowed claim; under it, each gap falls back to develop's behaviour. Filed (related to #4282).
- #4195: agent-scoped rules (the reporter's setup) are still blocked with this PR, because
tool.execute.beforecarries no agent. Comment added with the transcript. - OpenCode 2.x plugin loading: #4187 (pre-existing).
Checked and correct — no need to re-verify
wildcard_matchmatches 1.18.34'sWildcard.match: backslash normalisation, optional*tail,sflag. Last match wins, and the permission name is matched as a wildcard. 11 mutations on this code are killed.{"*": "allow", "git push *": "deny"}, live on 1.18.34: develop rewrote tortk git push --forceand OpenCode ran it. This PR leaves the command as typed and OpenCode denies it.- Every row of the PR-body table reproduces through
rtk hook opencode. rtk hook check --agent opencodenow reports OpenCode's verdict (develop reported Claude Code's).- With no rules, the rewrite still happens (
rtk ls -laoutput reaches the model). serde_jsonhaspreserve_order, so declaration order survives.- fmt, clippy and test on the head; CI 15/15.
Your questions
- "The plugin can't pass
--agentyet … Agent-scoped rules fall back to root config until that's sourced." → Deferred: #4195 stays open for agent-scoped rules. In this PR, drop--agentor pin it (3). - "
permission.asklifting adenyis not yet confirmed … the fallback is to skip the rewrite when it would downgrade the verdict" → Confirmed inert on every 1.x release, ask included. Fold in the fallback, using "would change" instead of "would downgrade" (1).
Next
Make three changes, then this merges:
- Replace the status channel with skipping any rewrite that would change the verdict (rows in 1).
- Rebase on develop with the three
user_dirsreads andOPENCODE_CONFIGinINHERITED_VARS(2). - Add tests that kill the mutations in 3.
Rounds: 1/3. Threads: 3 open (waiting on the author), 0 resolved this round.
OpenCode never consults plugins through permission.ask — no release from 1.1.4 to 2.0.22 has a trigger site for it — so the status channel could not deliver the verdict and #4195's allowed command stayed blocked, while an ask rule could be silently bypassed by the rewrite un-matching it. Replace the channel with the one mechanism that works on every version: judge both the typed command and its rtk form against OpenCode's own rules, and answer {} whenever the rewrite would change the verdict. OpenCode then runs the typed command under the user's own policy; an allow stays an allow, an ask stays a prompt, a deny stays denied. The plugin loses permission.ask, the pending map and the status field. Also post-rebase on develop: config discovery goes through user_dirs (home, current_dir, env_path) and OPENCODE_CONFIG joins INHERITED_VARS, so the test-isolation guards hold; rules load once per answer instead of twice; and tests pin the answer rows, config discovery order, agent-block ordering, in-process routing and the deny-before-unattestable aggregation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
48b15a9 to
4ddc78d
Compare
|
@KuSh — round 1 addressed in
Optional items done too. CLA: author email fixed in this push. Note for #4187/#4422: 2.x merges |
…aims Reading the topmost project config instead of the nearest passed every test. Name the OPENCODE_CONFIG test for what rtk does (OpenCode loads that file on top of the global one), and limit the permission.ask note to the releases without that hook: OpenCode 1.0.142 has it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KuSh
left a comment
There was a problem hiding this comment.
Round 2 of 3 — APPROVED (after fixup bb6361f)
Claim: the OpenCode plugin judges a command against OpenCode's own root and project permission rules, and skips the rewrite whenever the rewrite would change that verdict (#4195).
Scope: accept if narrowed to "the OpenCode plugin judges the command against OpenCode's own root and project rules, and skips the rewrite whenever the rewrite would change that verdict" — the head now makes exactly this claim. (round 1, frozen)
Ran: OpenCode 1.1.4, 1.3.4 and 1.18.34, develop plugin vs this head's plugin. A mock model issues one real bash call per session, across 10 policies:
*: deny+git status: allow, on all three versions- the reporter's
npx ts-node*task-cli*allow, at root level git push *: ask*: allow+git push *: deny*: allow+head *: deny(a structural rewrite tortk read)- no rules,
*: ask,bash: allow - allow rules with no catch-all
Also: rtk hook opencode and rtk hook check --agent opencode on 15 rows. Invariants: every decision (allow, ask, deny, no rule), under the hook (the plugin is the hook), merge with develop (the head is on c356374). Savings: n/a (no filter change).
Pins: 21 decisions in the new code, 21 mutated, 4 survived. 3 are equivalent: the empty-command guard, empty actions → Allow, joining the args. The 4th, nearest vs topmost project config, is pinned by fixup bb6361f, whose 3 mutations are all killed. The matcher is unchanged since round 1, where its 11 mutations were killed.
Matrix: executed this round on 4ddc78d; pin check: executed this round
CI @bb6361f7: 16/16 green (4ddc78d was 16/16 too), CLA pending.
Blocks merge (3, frozen at round 1) — all closed
rtk.ts:55, status channel: fixed. Live on 1.18.34, 1.3.4 and 1.1.4, the #4195 policy now runs the typedgit status. The reporter's root-levelnpxpolicy runs the typed command.git push *: askprompts forgit push origin main; develop ranrtk git push origin mainsilently. Every row passes on this head:git push *: denystill denies; develop ranrtk git push --force.head *: denydenieshead -n 1 a.txt; develop ranrtk read a.txt --head-lines 1.- No rules,
*: askandbash: allowstill get the rewrite.
permissions_opencode.rs:170, test isolation: fixed. fmt, clippy (0 warnings) andcargo test --all(4146 passed) are green on the head, which already sits on the develop tip.hook_cmd.rs:1200, unpinned behaviour: fixed. 17 of the 21 new mutations are killed. 3 of the 4 survivors cannot change an answer, and fixup bb6361f pins the last one.
Fixup bb6361f (pushed to the branch)
This commit changes only tests and one doc note: the release binary is byte-identical to 4ddc78d's.
- New test:
the_nearest_project_config_is_the_one_read. Reading the topmost project config instead of the nearest passed every test. OPENCODE_CONFIGtest renamed toopencode_config_env_is_read_instead_of_the_global_file, with a corrected message. OpenCode 1.18.34 loads the global file, thenOPENCODE_CONFIG, then the project file (opencode debug config), so "as OpenCode does" was not true. That difference is part of #4422.permission.asknote: OpenCode from 1.1.4 on has no such plugin hook, but 1.0.142 hasPlugin.trigger("permission.ask", …). My round-1 line "OpenCode never calls apermission.askplugin hook" was broader than what I had checked (1.1.4 on), and the note followed it.
Follow-up PR, right after this merges
We'll open it from the maintainer side. It will do the following:
-
One rule set per host, loaded once. OpenCode's ordered list currently sits beside
load_rules_for(host)and overrides it:check_command_for_agentshort-circuits it, and it returns empty lists for OpenCode. The bucket hosts and OpenCode will go through the same abstraction, loaded once and used for both the typed command and its rewrite. -
rtk hook check --agent opencodewill give the plugin's answer. On this head it printsrtk <cmd>with exit 0 wherertk hook opencodeanswers{}, in 5 cases:{"*":"deny","git status":"allow"}withgit status{"git push *":"ask"}withgit push origin main- the reporter's
npxpolicy {"git status":"allow"}withgit status{"rtk *":"ask"}withgit status
A table test comparing the two will pin it.
Filed as follow-ups
- #4422: added a comment. rtk does not model OpenCode's built-in
*: allow, so allow rules with no catch-all lose the rewrite for exactly the commands they allow. That costs savings, never a verdict. - #4195 stays open for agent-scoped rules (round 1).
- OpenCode 2.x: #4187. Your note that 2.x merges
.json+.jsonc, asks on unmatched commands and matches arity prefixes is the pass that PR has to make onpermissions_opencode.rs.
Checked and correct — no need to re-verify
- The skip is in the right place: 0 rows where the plugin changes a verdict, on 3 OpenCode versions.
split_for_permissionsreturns trimmed, non-empty segments (both push sites), so removing the filter is safe.- The plugin's logic is the round-1 narrowing exactly; only its comments differ.
- The head is on the current develop tip, so it is its own merge check.
Your questions
- "2.x merges
.json+.jsonc, defaults unmatched to ask, and matches arity prefixes —permissions_opencode.rswill need a pass when 2.x plugin loading is fixed." → Agreed, and deferred to #4187, which lands after this.
Next
Fixup bb6361f is pushed and code-reviewed, and CI is green on it. It adds a test for the nearest project config and corrects one test message and one note, with no behaviour change. Approving. The CLA check still has to pass before merge.
The PR description is updated to match the narrowed design.
Rounds: 2/3. Threads: 0 open, 3 resolved this round.
…51.1 KuSh's review of rtk-ai#2426 named two gaps that also apply here: the plugin decided usability from the file's existence, and set no minimum version. existsSync only proves a file is there. A wrong-arch binary, a broken install, or a stale RTK_BIN all pass that check and then fail on every single tool call. Spawning `rtk --version` once per session is the check that actually means something, and the answer is cached per binary. The floor is 0.51.1, not 0.51.0: `rtk hook opencode` was added in rtk-ai#4349 and exists in no released version -- v0.51.0's src/main.rs has no HookCommands::Opencode variant. Without the gate a 0.51.0 user gets a plugin that registers hooks, delegates to a missing subcommand, and silently passes every command through. A develop build reports 0.49.0 because release-please only bumps Cargo.toml on master, and the warning it gets is accurate: such a build has no release version. An unparseable banner is not evidence of an old rtk, so it passes and the delegation call still fails open.
…51.1 KuSh's review of rtk-ai#2426 named two gaps that also apply here: the plugin decided usability from the file's existence, and set no minimum version. existsSync only proves a file is there. A wrong-arch binary, a broken install, or a stale RTK_BIN all pass that check and then fail on every single tool call. Spawning `rtk --version` once per session is the check that actually means something, and the answer is cached per binary. The floor is 0.51.1, not 0.51.0: `rtk hook opencode` was added in rtk-ai#4349 and exists in no released version -- v0.51.0's src/main.rs has no HookCommands::Opencode variant. Without the gate a 0.51.0 user gets a plugin that registers hooks, delegates to a missing subcommand, and silently passes every command through. A develop build reports 0.49.0 because release-please only bumps Cargo.toml on master, and the warning it gets is accurate: such a build has no release version. An unparseable banner is not evidence of an old rtk, so it passes and the delegation call still fails open.
…ward the agent KuSh's review round 1, items 1a, 1b, 1c and 1e. 1c — the version floor was the wrong instrument, and my own floor made it worse. `--version` cannot tell the two populations apart: a develop build reports `rtk 0.49.0` because release-please only bumps Cargo.toml on master, and so does every pre-rtk-ai#4349 release. The 0.51.1 floor therefore disabled the plugin on the very binary that had just installed it, while letting a real 0.51.1-rc through. `rtk hook opencode --help` separates them: exit 0 with the subcommand, exit 2 without it. One call, and it subsumes "does this binary even run" — a broken or wrong-arch binary fails to spawn at all. The floor and the version comparison go with it, and with them the warning that printed `rtk rtk …`, lost the leading zero, and claimed a present subcommand was missing. 1a/1b — binary discovery narrows to PATH. The RTK_BIN, ~/.cargo/bin, ~/.local/bin, Homebrew and expandHome search was the original Windows fix, but OpenCode's shell tool runs commands with OpenCode's own PATH and never sources .profile, .bashrc or .bash_profile — an rtk found anywhere else gets rewritten into a command that comes back `rtk: command not found`. Finding it anyway is worse than not rewriting. KuSh triaged that as rtk-ai#4462 rather than blocking on it; this commit implements the narrowed claim. A PATH entry that is a directory is skipped instead of frozen as the answer, which used to disable the plugin for the whole session. 1e — OpenCode 2.x sends event.agent; rtk already reads agent.<name>.permission on top of the root rules, which is what OpenCode itself applies. Without it the rewrite silenced an agent-scoped ask on `ls *` and an agent-scoped deny on `ls -la` never saw the command. 1.x sends no agent field, so that path stays root-only and the README says so. The tests are rewritten around the two things the old suite never did: it never invoked a registered hook, and it mocked rtk by writing into process.cwd(). node is now copied into a temp directory as `rtk`/`rtk.EXE` with a `hook` script beside it and the suite chdirs there, so the shipped code path runs end to end — PATH discovery, the probe, argv construction, and the callbacks OpenCode 2.0.22 and 1.18.34 actually call. Both entrypoints are invoked with their real event shapes, an unusable rtk is pinned to register nothing, the agent is asserted in the argv, and the probe cache and RTK_DISABLED checks are pinned against a live rtk so deleting either line fails the suite. 13 of 13 mutations from the review die on it. `hooks/README.md`'s OpenCode snippet assigned `args.command` from inside a non-awaited execFile callback, so anyone copying it got a plugin that rewrites nothing. It awaits now.
KuSh's review round 1, item 1f, and the handoff note aeppling left on rtk-ai#4349. rtk only read the legacy `permission` map, so a 2.x user who wrote the rules OpenCode 2 actually documents had them silently ignored — the plugin then judged rewrites against an empty rule set and returned rewrites that OpenCode would have asked about or denied. Three shapes were missing: - `permissions: [{action, resource, effect}]`, the 2.x native list. Entries for other tools are dropped; one without a `resource` covers every command. - `permission.shell`, the 2.x name of the `bash` axis. Rules are stored under the axis the evaluator matches on, so both spellings land on `bash`. - `agents.<name>.permissions`, the 2.x agent block. 1.x's `agent.<name>` still loads, after it. Ordering is load-bearing and is now explicit: the legacy map first, the 2.x list after it, within each scope, regardless of the order the file declares them. A legacy allow plus a list deny has to come out denied — otherwise a user who loosened the old map and then denied in the new one would be handed the allow. Global before project, and the agent block last, both unchanged.
Alternative to #4306 for #4195.
Why not #4306
#4306 injects
rtk <pattern>rules into the user's effective config until the rewritten command passes the gate.head -n 5 f→rtk read f --head-lines 5), where nortk-prefixed pattern matches.denyon such a command therefore stays bypassed, while the added docs claim permissions are preserved.opencodestays onViaRewrite, judged against~/.claude's rules rather than its own.Direction
Decide on the command as typed, against OpenCode's own rules, and skip the rewrite whenever it would change that verdict. The user's config is never touched.
Before
After
opencodemoves fromViaRewrite(ApprovalOwner::Rtk)toInProcess(Host::OpenCode)— the same shape as Claude, Codex, Cursor, Gemini, Droid, Trae and Vibe. Approval ownership is unchanged.OpenCode's rules are an ordered list where the last match wins, which the deny/ask/allow buckets cannot express, so
permissions_opencodereproduces its resolution for the global config (orOPENCODE_CONFIG) and the nearest project config. The existing evaluator is untouched — the other nine hosts are byte-identical.OpenCode judges whatever command the plugin hands back, and from 1.1.4 on a plugin cannot change that verdict (there is no
permission.askplugin hook). So RTK returns a rewrite only when it leaves the verdict unchanged: an allow stays an allow, an ask stays a prompt, a deny stays denied. A command that loses its rewrite runs as typed, without the token savings.Verified against real OpenCode installs
Plugin sessions on 1.1.4, 1.3.4 and 1.18.34 (
rtk hook opencodeon the pushed code for the rows below):"*": deny+"git status": allowgit status{}git statusrm -rf /{}git status && git push --force{}"git push *": askgit push origin main{}"*": allow+"git push *": denygit push --force{}"*": allow+"head *": denyhead -n 5 f{}rtk read)"git status": allowonlycargo test{"command":"rtk cargo test"}ls -la{"command":"rtk ls -la"}Open
tool.execute.beforeexposes{tool, sessionID, callID}with no agent field, so the plugin cannot pass--agentand agent-scoped rules are not seen. [OpenCode] RTK rewrite breaks existing Bash permission allowlists by addingrtkbefore permission evaluation #4195 stays open for them.rtk hook check --agent opencodedoes not yet apply the same skip, and OpenCode's rule list sits besideload_rules_forrather than going through it. Both are for a follow-up PR.