Conversation
The plugin API declares a `permission.ask` hook, but nothing ever calls it. `trigger` is invoked in six places and none of them is this hook, so a plugin that registers it is silently ignored. Give the permission service a reviewer, and register one from the plugin layer that runs the hook. The dependency points plugin -> permission on purpose: the reverse edge would drag plugin loading into every graph that builds Permission on its own, which several tests do. The hook is now declared with the shape it is actually handed, so both `as any` casts are gone. It receives copies of `patterns`, `metadata` and `always`, so a hook cannot rewrite the request the user is about to be shown. It sees the id the real request will carry. A status other than allow/ask/deny keeps the rule decision instead of failing open, and a hook that throws does the same rather than taking the permission check down. A denial can carry the plugin's own message, so the model is no longer told the user wrote a rule that does not exist. A rule that already denies settles the request before the hook runs, so the hook can observe allow and ask only. Co-Authored-By: Александр <lild1tz2023@gmail.com> Co-Authored-By: Timur Polishchuk <timur.polishchuk.official@gmail.com>
|
The following comment was made by an LLM, it may be inaccurate: Based on my search, I found several related PRs that address the permission.ask plugin hook: Potential Duplicates/Related PRs:
The most likely duplicate/related work is PR #42633 ("feat(permission): restore permission ask hook safely"), which has the same core objective of implementing the documented but non-functional permission.ask hook. You should verify if that PR was closed/merged and whether the current PR (47675) is a revival or improvement of that work. |
Every hook shares one `output` object, so a failure can follow a decision an earlier hook already made. The catch restored the pre-hook status unconditionally, which discarded a `deny` a well-behaved hook had set. Restore only when keeping what the failing hook left would be more permissive than the rules were. Co-Authored-By: Александр <lild1tz2023@gmail.com> Co-Authored-By: Timur Polishchuk <timur.polishchuk.official@gmail.com>
fwa-wup
left a comment
There was a problem hiding this comment.
Independently tested on a build of this PR branch — I maintain a downstream plugin that depends on permission.ask (see my 2026-09-01 note on #7006; I verified #42633 the same way before it was stale-bot-closed).
Build — pull/47675/head (bda782e) in a clean clone; bun install (the tree-sitter-powershell postinstall flaked once, unrelated to this PR, clean on retry); bun run script/build.ts --single --skip-install → standalone linux-x64 binary, smoke test passed.
Unit — bun test test/plugin/permission-ask.test.ts test/permission/ test/plugin/trigger.test.ts test/session/tools.test.ts → 97 pass / 0 fail (includes the PR's new hook tests). tsgo --noEmit in packages/opencode → clean.
End-to-end, isolated env, opencode run mode — inert probe plugin first, then my real plugin via file://:
- Probe: the hook fires on this build (marker written, awaited before any prompt path). Stock 1.18.31 with the identical probe: never fires. This is the first living PR whose build I could make the hook fire.
- Acceptance matrix (classifier-backed plugin, real provider):
- SAFE-classified
touch …→ auto-allowed, command ran with no human answer — proves the reviewer's output is honored before the prompt path. - UNSAFE
rm -rf .→ stayed blocked, marker intact. .envread → stayed ask-blocked. Honest note: this cell failed my first matrix run because the classifier model returned SAFE for the.envread (downstream model nondeterminism, not this PR — the hook fired and its verdict was honored correctly both times); the re-run was MATRIX: ALL PASS.
- SAFE-classified
Payload-shape confirmation (relevant for release notes, as your description already flags): the hook really receives the v2 PermissionRequest shape — captured {permission: "bash", patterns: ["touch C1-MARKER.txt"]}, no type/pattern. Plugins written against the previously declared legacy type will read undefined there; my plugin had to be fixed for exactly this (I only discovered it because your PR made the hook fire somewhere).
Fail-closed choices look right from the outside too: unknown status keeps the rule decision, a throwing hook keeps the safer decision, and a configured deny settling before the hook matches what a guard plugin expects (our plugin's own deny short-circuit never needs to relax user config).
👍 on the PR itself — the stale-PR cleanup bot closed #42633 (verified-but-unreviewed) two days ago for <2 positive reactions; please don't let this one meet the same fate.
|
Thanks — that is the verification I could not do for my own PR, and the payload capture is the part I would point a reviewer at. The legacy Following your #7006 link led me to something that I think strengthens the case for every PR in that thread, including the ones already closed. @athal7's bisect there is right, and it can be pinned exactly. At // packages/opencode/src/permission/index.ts:127
switch (
await Plugin.trigger("permission.ask", info, {
status: "ask",
}).then((x) => x.status)At So this is a regression from a refactor, not a feature that was never built. The declaration in |
Issue for this PR
Closes #47674
Type of change
What does this PR do?
packages/plugin/src/index.tsdeclares apermission.askhook. Nothing callsit —
triggerhas six call sites and this is not one of them — so a plugin thatregisters the hook never runs.
This wires it up. The permission service gains a reviewer that the plugin layer
registers, and
Permission.askruns the hook on the decision the rulesproduced. The hook can leave it, tighten it, or relax it, and can explain a
denial.
Four things I fixed while wiring it, because the hook is on the permission path:
Permissionshape, which sharesthree of nine fields with what is actually passed. A plugin written against
the published type would read
undefinedfortype,patternandtitle.It now declares
PermissionRequestfrom@opencode-ai/sdk/v2, which is theshape the service really hands over, and both
as anycasts are gone.patterns/metadata/alwaysarrays,which are the same objects the pending request uses. It now gets copies.
use. It now sees the id the request will carry.
that threw escaped
ask's declared error channel and killed the tool call.Both now keep the decision the rules made, and log.
Denials no longer synthesise a rule the user never wrote;
DeniedErrorgainedan optional
reasonthe message prefers.Not in scope, and worth saying: a rule that denies settles the request before
the hook runs, so a plugin cannot relax a configured deny. Hooks in this
codebase have no timeout —
triggerruns them all throughEffect.promise—and this PR does not change that for other hooks.
How did you verify your code works?
New test file
packages/opencode/test/plugin/permission-ask.test.ts, sevencases: allow/ask/deny each take effect; an unrecognised status does not
auto-allow; a throwing hook leaves both an allowing and an asking rule in
charge; and an allow written before a throw is discarded.
I checked the tests are not vacuous by removing the
Effect.catchCausewrapper(the three throwing-hook cases fail) and separately by dropping only the status
restore (just the "allow then throw" case fails).
All of the above was run locally with AI assistance rather than typed by hand.
I read the resulting diff myself and can explain every line of it.
Screenshots / recordings
N/A — no UI change.
Checklist