Skip to content

feat: add onPermissionRequest callback for host-driven per-call gating#299

Open
DaniAkash wants to merge 4 commits intoopenclaw:mainfrom
DaniAkash:feat/on-permission-request-callback
Open

feat: add onPermissionRequest callback for host-driven per-call gating#299
DaniAkash wants to merge 4 commits intoopenclaw:mainfrom
DaniAkash:feat/on-permission-request-callback

Conversation

@DaniAkash
Copy link
Copy Markdown

What

Adds an optional onPermissionRequest callback to AcpRuntimeOptions and AcpClientOptions. When set, the host receives every per-call permission request the agent emits and can resolve it with its own UI; when unset (the default), behavior is identical to today's mode-based resolver.

Why

Today's permission surface (permissionMode + nonInteractivePermissions) is the right primitive for CLI use, but it's coarse for UI-driven hosts. A browser extension or IDE integration that wants to render an inline approve/deny card for apply_patch / shell / delete has to either:

  • pick approve-all and skip approval entirely (unsafe), or
  • pick approve-reads + nonInteractivePermissions: "deny" and watch every write get silently denied with no signal to the user.

A callback hook gives those hosts a clean, opt-in way to gate per-call without changing any defaults. The mode-based resolver remains the fallback — it still runs when the callback returns undefined, throws, or isn't set.

This change is purely additive: zero behavior change for any existing consumer.

Wire shape

The host receives:

type AcpPermissionRequest = {
  sessionId: string;
  raw: RequestPermissionRequest;       // full ACP request, untouched
  inferredKind: ToolKind | undefined;  // best-effort classification
};

…and returns:

type AcpPermissionDecision =
  | { outcome: "allow_once" }
  | { outcome: "allow_always" }
  | { outcome: "reject_once" }
  | { outcome: "reject_always" }
  | { outcome: "cancel" };

…or undefined to fall through. The decision outcomes map 1:1 to the SDK's PermissionOption["kind"] strings, so a new decisionToResponse helper in src/permissions.ts resolves them against whatever options the agent advertised — hosts never see optionId strings. The existing inferToolKind helper is now exported so hosts can render kind-aware UI without re-implementing the keyword classifier.

The gate sits at the top of AcpClient.handlePermissionRequest, before the existing resolvePermissionRequest call. Throws inside the host callback are caught + logged + fall through to the mode-based resolver, so a buggy host UI can't take down an agent turn. A per-session AbortSignal is exposed to the callback and aborts when the session is cancelled or the client is reset.

Tests

  • 5 new unit cases in test/permissions.test.ts covering decisionToResponse option mapping (allow_once / allow_always preference order, fallback when one allow kind is absent, reject_oncereject_always fallback, cancel always cancels, missing-option cancel).
  • 4 new integration cases in test/client.test.ts covering the gate (decision honored over mode, undefined fallthrough, thrown error fallthrough, abort signal aborts on session cancel).
  • pnpm run check (format + typecheck + lint + build + viewer + test:coverage) passes locally — 613 tests, coverage 84.09 / 78.46 / 88.98 (above 83 / 76 / 86 thresholds).

Changelog

Entry added under ## Unreleased### Changes per AGENTS.md.

Driving consumer

acpx-ai-provider needs this to allow approve / deny options on the Vercel AI SDK so it can be integrated with UI components built with the Vercel AI SDK. Other tools can leverage it the same way.

DaniAkash added 4 commits May 6, 2026 19:31
Adds an optional `onPermissionRequest` hook to `AcpRuntimeOptions` and
`AcpClientOptions`. When set, every per-call permission request the
agent emits is routed through the host's callback first; returning a
decision short-circuits the existing mode-based resolver, returning
`undefined` (or throwing) falls through to it.

Existing CLI users and programmatic consumers see zero behavior change
— the option is undefined by default, and the mode-based path is
preserved verbatim. Hosts that want to render approve/deny UI can now
opt in without reaching into acpx internals.

The wire shape:

  type AcpPermissionRequest = {
    sessionId: string;
    raw: RequestPermissionRequest;       // full ACP request, untouched
    inferredKind: ToolKind | "other";    // best-effort classification
  }

  type AcpPermissionDecision =
    | { outcome: "approve_once" }
    | { outcome: "approve_always" }
    | { outcome: "deny" }
    | { outcome: "cancel" };

A new `decisionToResponse` helper in `src/permissions.ts` maps the
decision to the right ACP `RequestPermissionResponse` shape based on
the options the agent advertised — hosts never see `optionId` strings.
The existing `inferToolKind` is now exported so hosts can render
kind-aware UI without re-implementing the keyword classifier.

The client gate in `handlePermissionRequest` runs the callback before
the existing mode-based resolver, exposes a per-session `AbortSignal`
the callback can honor, and falls through cleanly on `undefined` /
thrown errors. The signal aborts when the session is cancelled so
in-flight host UI can short-circuit. Throws are logged and don't take
down the turn.

Driving consumer: `acpx-ai-provider` needs this to allow approve /
deny options on the Vercel AI SDK so it can be integrated with UI
components built with the Vercel AI SDK
(https://www.npmjs.com/package/acpx-ai-provider). Other tools can
leverage it the same way.

Tests: 5 new unit cases in `test/permissions.test.ts` cover
`decisionToResponse` option-mapping; 4 new integration cases in
`test/client.test.ts` cover the gate (decision honored / undefined
fallthrough / thrown error fallthrough / abort signal propagation).
`pnpm run check` passes locally — 613 tests, coverage 84.16% lines /
78.46% branches / 88.98% functions, all above thresholds.
- drop JSDoc blocks on new types and helpers to match the surrounding
  zero-JSDoc convention used by `permissionModeSatisfies`,
  `resolvePermissionRequest`, `classifyPermissionDecision`, etc.
- inline the callback signature in `AcpClientOptions`,
  `AcpRuntimeOptions`, and `WithConnectedSessionOptions` to mirror the
  inline shape used by `onSessionUpdate`, `onClientOperation`, etc.;
  drop the `AcpOnPermissionRequest` type alias and its re-exports.
- rename decision outcomes from `approve_once | approve_always | deny |
  cancel` to `allow_once | allow_always | reject_once | reject_always |
  cancel`, mapping 1:1 to the SDK's `PermissionOption["kind"]` strings.
  This removes the parallel vocabulary and lets `decisionToResponse`
  collapse to a single fallback-order table lookup.
- expose `inferredKind` as `ToolKind | undefined` (matching
  `inferToolKind`'s own return convention) instead of substituting
  `"other"`.
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.

1 participant