Skip to content

fix(opencode): run the documented permission.ask plugin hook - #47675

Open
Atmostone wants to merge 2 commits into
anomalyco:devfrom
Atmostone:permission-ask-hook
Open

fix(opencode): run the documented permission.ask plugin hook#47675
Atmostone wants to merge 2 commits into
anomalyco:devfrom
Atmostone:permission-ask-hook

Conversation

@Atmostone

Copy link
Copy Markdown

Issue for this PR

Closes #47674

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Documentation

What does this PR do?

packages/plugin/src/index.ts declares a permission.ask hook. Nothing calls
it — trigger has six call sites and this is not one of them — so a plugin that
registers the hook never runs.

This wires it up. The permission service gains a reviewer that the plugin layer
registers, and Permission.ask runs the hook on the decision the rules
produced. 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:

  • The declared input type was the legacy SDK Permission shape, which shares
    three of nine fields with what is actually passed. A plugin written against
    the published type would read undefined for type, pattern and title.
    It now declares PermissionRequest from @opencode-ai/sdk/v2, which is the
    shape the service really hands over, and both as any casts are gone.
  • The hook was handed the caller's own patterns/metadata/always arrays,
    which are the same objects the pending request uses. It now gets copies.
  • It was handed a freshly minted id that no later event or reply would ever
    use. It now sees the id the request will carry.
  • Any status other than the three legal values read as auto-allow, and a hook
    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; DeniedError gained
an optional reason the 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 — trigger runs them all through Effect.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, seven
cases: 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.

bun test test/plugin/permission-ask.test.ts --timeout 30000   →  7 pass, 0 fail
bun test test/plugin/ test/permission/ test/session/tools.test.ts
                                                              →  303 pass, 0 fail (23 files)
bun run --cwd packages/opencode typecheck                     →  clean

I checked the tests are not vacuous by removing the Effect.catchCause wrapper
(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

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

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>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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:

  1. feat(permission): restore permission ask hook safely (PR feat(permission): restore permission ask hook safely #42633)

    • Similar goal of restoring/implementing the permission.ask plugin hook
  2. feat(permission): wire permission.ask plugin hook (PR feat(permission): wire permission.ask plugin hook #30509)

    • Directly related to wiring up the permission.ask hook
  3. fix(opencode): add permission.ask plugin hook back (PR fix(opencode): add permission.ask plugin hook back #19453)

    • Historical attempt to add the permission.ask hook back
  4. feat(core): optional plugin gate in PermissionV2 for allow→ask (PR feat(core): optional plugin gate in PermissionV2 for allow→ask #34329)

    • Related to permission plugin gating logic
  5. refactor(core): split permission policy from global ledger (PR refactor(core): split permission policy from global ledger #46706)

    • May have related changes to permission architecture

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

permission.ask plugin hook is never triggered

1 participant