[PR #12742] feat: add permission.ask and prompt.submit plugin hooks #14349

Closed
opened 2026-02-16 18:19:09 -05:00 by yindo · 0 comments
Owner

Original Pull Request: https://github.com/anomalyco/opencode/pull/12742

State: closed
Merged: No


Summary

Enables plugins to intercept permission decisions and user prompt submissions, unlocking LLM-driven tool policy gates and automated permission management.

Motivation

OpenCode's current permission system evaluates tool calls against a static ruleset, and when the result is "ask", it always prompts the user. There's no way for a plugin to programmatically make this decision.

This is a problem for users who want to build automated policy gates — for example, using a fast LLM (like Claude Haiku) to evaluate whether a bash command is safe before bothering the user. Claude Code supports this via its PreToolUse and UserPromptSubmit hooks, but OpenCode's PermissionNext system had no equivalent plugin integration point. The legacy Permission namespace had a permission.ask hook (at permission/index.ts:134), but PermissionNext — the active system — did not.

Similarly, there's no mechanism for plugins to react when a user submits a prompt, which is needed for extracting implicit permission grants from user language (e.g., "you can force push to any branch").

Changes

1. permission.ask plugin hook (packages/opencode/src/permission/next.ts)

When PermissionNext.evaluate() returns "ask", the new hook fires before creating the pending promise and publishing to the bus:

evaluate() returns "ask"
  → Plugin.trigger("permission.ask", { permission, pattern, sessionID, metadata, tool }, { status: "ask" })
  → if plugin sets status to "allow" → continue (skip user prompt)
  → if plugin sets status to "deny" → throw DeniedError
  → if status remains "ask" → fall through to existing user prompt behavior

This mirrors the pattern from the legacy Permission system but with richer context.

2. prompt.submit plugin hook (packages/opencode/src/session/prompt.ts)

Fires after createUserMessage() but before the agent loop starts. Extracts text parts from the user's input and passes them to plugins:

await Plugin.trigger("prompt.submit", { sessionID, text }, {})

3. PermissionNext.grant() (packages/opencode/src/permission/next.ts)

Public API for dynamically injecting allow rules into the approved ruleset at runtime. This lets the prompt.submit hook's handler add permission rules extracted from user language.

4. POST /permission/grant route (packages/opencode/src/server/routes/permission.ts)

HTTP endpoint exposing grant() for plugins that communicate via the server API.

5. Updated hook types (packages/plugin/src/index.ts)

  • permission.ask hook now receives richer context: { permission, pattern, sessionID, metadata, tool? }
  • New prompt.submit hook type: { sessionID, text }

Example Plugin Usage

export default async function(input) {
  return {
    "permission.ask": async (info, output) => {
      // Call an LLM to evaluate whether this tool call is safe
      const decision = await evaluateWithLLM(info.permission, info.pattern, info.metadata)
      if (decision === "allow" || decision === "deny") {
        output.status = decision
      }
      // "ask" falls through to the normal user prompt
    },
    "prompt.submit": async (info, output) => {
      // Extract permission grants from user language
      const permissions = await extractPermissions(info.text)
      if (permissions.length > 0) {
        await fetch(`${input.serverUrl}permission/grant`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ rules: permissions.map(p => ({
            permission: "bash", pattern: `*${p}*`, action: "allow"
          })) }),
        })
      }
    },
  }
}

Design Decisions

  • Plugin hook runs synchronously before the user prompt — if the plugin says "allow", the user never sees the prompt. This is critical for a smooth experience with automated policy gates.
  • Fail-safe to "ask" — if no plugin modifies the output, or if the plugin errors, the default behavior is unchanged (user gets prompted).
  • grant() is in-memory only — consistent with the existing reply("always") behavior which also only writes to s.approved in memory (the TODO at line 223-225 to persist to disk is still pending).
  • No breaking changes — the permission.ask hook type is updated with richer input fields, but the output shape { status } is unchanged. Existing plugins using the old Permission type input would need to update, though no known plugins use this hook since it was never wired up in PermissionNext.
**Original Pull Request:** https://github.com/anomalyco/opencode/pull/12742 **State:** closed **Merged:** No --- ## Summary Enables plugins to intercept permission decisions and user prompt submissions, unlocking LLM-driven tool policy gates and automated permission management. ## Motivation OpenCode's current permission system evaluates tool calls against a static ruleset, and when the result is `"ask"`, it always prompts the user. There's no way for a plugin to programmatically make this decision. This is a problem for users who want to build automated policy gates — for example, using a fast LLM (like Claude Haiku) to evaluate whether a bash command is safe before bothering the user. Claude Code supports this via its `PreToolUse` and `UserPromptSubmit` hooks, but OpenCode's `PermissionNext` system had no equivalent plugin integration point. The legacy `Permission` namespace had a `permission.ask` hook (at `permission/index.ts:134`), but `PermissionNext` — the active system — did not. Similarly, there's no mechanism for plugins to react when a user submits a prompt, which is needed for extracting implicit permission grants from user language (e.g., "you can force push to any branch"). ## Changes ### 1. `permission.ask` plugin hook (`packages/opencode/src/permission/next.ts`) When `PermissionNext.evaluate()` returns `"ask"`, the new hook fires **before** creating the pending promise and publishing to the bus: ``` evaluate() returns "ask" → Plugin.trigger("permission.ask", { permission, pattern, sessionID, metadata, tool }, { status: "ask" }) → if plugin sets status to "allow" → continue (skip user prompt) → if plugin sets status to "deny" → throw DeniedError → if status remains "ask" → fall through to existing user prompt behavior ``` This mirrors the pattern from the legacy `Permission` system but with richer context. ### 2. `prompt.submit` plugin hook (`packages/opencode/src/session/prompt.ts`) Fires after `createUserMessage()` but before the agent loop starts. Extracts text parts from the user's input and passes them to plugins: ```ts await Plugin.trigger("prompt.submit", { sessionID, text }, {}) ``` ### 3. `PermissionNext.grant()` (`packages/opencode/src/permission/next.ts`) Public API for dynamically injecting allow rules into the approved ruleset at runtime. This lets the `prompt.submit` hook's handler add permission rules extracted from user language. ### 4. `POST /permission/grant` route (`packages/opencode/src/server/routes/permission.ts`) HTTP endpoint exposing `grant()` for plugins that communicate via the server API. ### 5. Updated hook types (`packages/plugin/src/index.ts`) - `permission.ask` hook now receives richer context: `{ permission, pattern, sessionID, metadata, tool? }` - New `prompt.submit` hook type: `{ sessionID, text }` ## Example Plugin Usage ```ts export default async function(input) { return { "permission.ask": async (info, output) => { // Call an LLM to evaluate whether this tool call is safe const decision = await evaluateWithLLM(info.permission, info.pattern, info.metadata) if (decision === "allow" || decision === "deny") { output.status = decision } // "ask" falls through to the normal user prompt }, "prompt.submit": async (info, output) => { // Extract permission grants from user language const permissions = await extractPermissions(info.text) if (permissions.length > 0) { await fetch(`${input.serverUrl}permission/grant`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rules: permissions.map(p => ({ permission: "bash", pattern: `*${p}*`, action: "allow" })) }), }) } }, } } ``` ## Design Decisions - **Plugin hook runs synchronously before the user prompt** — if the plugin says "allow", the user never sees the prompt. This is critical for a smooth experience with automated policy gates. - **Fail-safe to "ask"** — if no plugin modifies the output, or if the plugin errors, the default behavior is unchanged (user gets prompted). - **`grant()` is in-memory only** — consistent with the existing `reply("always")` behavior which also only writes to `s.approved` in memory (the TODO at line 223-225 to persist to disk is still pending). - **No breaking changes** — the `permission.ask` hook type is updated with richer input fields, but the output shape `{ status }` is unchanged. Existing plugins using the old `Permission` type input would need to update, though no known plugins use this hook since it was never wired up in `PermissionNext`.
yindo added the pull-request label 2026-02-16 18:19:09 -05:00
yindo closed this issue 2026-02-16 18:19:09 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#14349