Skip to content

[FEATURE]: Add a skip field to tool.execute.before (for deterministic pre-execution gating) #52837

Description

@joeskeen

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

Hi — I just watched the Fireship video on OpenAPPA ("Did a 50 year old military secret just solve agent prompt injection?") and immediately thought: this would be a great thing to be able to use with OpenCode. The blocker is one missing primitive on the plugin side, which is what this issue is about. APPA just happens to be the first concrete use case I have — the change is generally useful for any plugin that wants to deterministically deny a tool call before it runs.

Related history

This has been requested before, all closed by automation rather than on the merits:

  • #20013 — same args being discarded at call sites; closed by 60-day-inactivity bot.
  • #32565 — same primitive (skip/result on tool.execute.before); auto-closed by the contributing-guidelines bot.
  • #26521 — cancel: string variant of the same ask; auto-closed by the contributing-guidelines bot.
  • #15933 — adjacent feature request (messageID in hook payloads); closed by 60-day-inactivity bot.

I'm refiling because the underlying primitive is still missing, and the use case (deterministic pre-execution gating) is what every policy/safety plugin needs. Happy to close this as a duplicate of one of the above if a maintainer points at a preferred canonical issue.

What's missing

A plugin can hook tool.execute.before today (packages/plugin/src/index.ts:266) and mutate output.args, but the mutated args are discarded at every call site I checked — packages/opencode/src/session/tools.ts:102-132, :175-219, :255-303, :325-385, :398-490, and packages/opencode/src/session/prompt.ts:301-380 all pass the closure-captured original args to item.execute(args, ctx) and ignore the trigger's output. So a plugin cannot currently block a tool call before it executes.

What I'd like

Add a new field to the trigger output, say skip: boolean, and honor it at each call site. Something like:

// packages/plugin/src/index.ts
"tool.execute.before"?: (
  input: { tool: string; sessionID: string; callID: string },
  output: { args: any; skip?: boolean },
) => Promise<void>
// packages/opencode/src/session/tools.ts (illustrative)
const hookOut = { args }
yield* plugin.trigger("tool.execute.before", { tool, sessionID, callID }, hookOut)
if (hookOut.skip) {
  // Synthesize a tool error the model can see.
  return { title: "blocked", output: "blocked by plugin", metadata: { error: "plugin-blocked" } }
}
yield* item.execute(hookOut.args, ctx)

That's it. ~30 lines across the call sites, plus a one-line doc update.

Why not permission.ask?

I considered that, but it's interactive — the user gets a prompt and the block is not deterministic. APPA's contract is "this value cannot flow to this sink, full stop" — no prompt, no override. So I can't use it without weakening the guarantee.

Why not throw?

tool.execute.before exceptions are caught and ignored by the plugin trigger (packages/opencode/src/plugin/index.ts:285-298), so throwing is silently swallowed. Not viable.

Backwards compatibility

The new skip field is opt-in (defaults to false). No existing plugin breaks.

What I'd do

If this lands, I'll maintain an OpenCode plugin that wraps OpenAPPA in the opencode-ai org. The plugin is straightforward and follows exactly the shape Amp's appa plugin uses today — OpenAPPA already serves Claude Code, Amp, and Kagent the same way.

I'm happy to:

  • Open a draft PR with the change.
  • Write a small unit test that asserts skip short-circuits the call.
  • Add the field to the plugin docs in packages/docs/.

Before I do that, two questions:

  1. Naming. skip? denied? output.skip is my preference because it parallels output.args and reads naturally ("the trigger skipped the call"). Open to alternatives.
  2. Where the skip surfaces to the model. Two options: (a) silent — the tool returns a synthetic error block the model sees as [blocked by plugin] <reason>, no TUI event; or (b) emit a permission.asked-style event so the TUI shows the block reason. Option (a) is what Amp plugins do ({ action: 'reject-and-continue' }); option (b) is more visible. Lean toward (a) for the first cut, but happy to do (b).

If this isn't a direction the maintainers want, I'd appreciate a pointer to a better extension point. The integration degrades to a post-hoc-only design without it (block-after-run), which is weaker but still shippable — so I'm not blocked on this landing, just constrained.

Thanks!

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions