PermDock
Adapters

Eve

permdock/eve turns PermDock decisions into Eve tool approval policies and approval response policies, takes principal and actor from the durable session's auth, and stores pending approvals in a pluggable ApprovalStore.

permdock/eve connects the Decision model to Eve's approval hook on defineTool. Eve already pauses a session durably and renders the approval on every channel; what it asks the application for is the policy that decides whether a call may run, must wait for a person, or is refused, and a second policy that decides who may press Approve. Both come out of one createPermDock call, typed against your permissions, fail-closed.

Purpose

Eve's human-in-the-loop hook receives the session context plus { toolName, toolInput, approvedTools, callId } and returns an AI SDK 7 approval status: "not-applicable" to continue, "user-approval" to pause, "approved" or "denied" (optionally { type, reason }) to decide in code. It offers never(), once() and always() helpers and leaves anything input- or caller-dependent to a custom function. Its response policy receives the submitted response (decision plus the authenticated principal) and decides whether that person may approve this call, and the docs note that a shared request stays pending when a responder is rejected so another eligible approver can act. That is a policy decision point with an approval store missing in the middle; permdock/eve supplies both from the application's PermDock policy and an ApprovalStore.

API

import { defineTool } from "eve/tools";
import { createPermDock } from "permdock/eve";

const DeleteInput = z.object({ id: z.string() });
const RefundInput = z.object({ chargeId: z.string(), amount: z.number() });

const { approval, approvalFor, permdock } = createPermDock(policy, {
  tools: {
    delete_post: {
      permission: permissions.post.delete,
      data: (input) => loadPost(DeleteInput.parse(input).id),
    },
    refund: {
      permission: permissions.charge.refund,
      data: (input) => loadCharge(RefundInput.parse(input).chargeId),
    },
  },
  store, // ApprovalStore; memoryApprovalStore() when omitted
  approvers: { roles: ["finance-admin"] }, // who may resolve; see below
  delegation: () => ({
    scopes: [permissions.post.delete.scope, permissions.charge.refund.scope],
  }),
});

export default defineTool({
  description: "Refund a charge.",
  inputSchema: RefundInput,
  approval, // { request, response } built from the tools map
  async execute(input, ctx) {
    // permdock(ctx) is the request-scoped PermDock for checks inside the tool
    (await permdock(ctx)).assert(
      permissions.charge.refund,
      await loadCharge(input.chargeId),
    );
    return refund(input);
  },
});

// Or gate a single tool without the map:
approval: approvalFor(permissions.charge.refund, (input) =>
  loadCharge(RefundInput.parse(input).chargeId),
);
  • approval is an Eve { request, response } pair, each taking the one context object Eve passes (EveApprovalContext for request, EveResponseContext for response). request looks the tool name up in tools (own keys only), resolves the resource, calls decide and maps the outcome. response checks the responder against the approval record and the approvers rule.
  • approvalFor(permission, data?) builds the same pair for one tool inline.
  • data receives the tool input as unknown; parse it with the tool's schema before loading the resource.
  • permdock(ctx) returns a request-scoped PermDock from ctx.session for checks inside execute or in other Eve code. It builds a fresh frozen instance on every call, so a replayed step never sees state from an earlier one.
  • tenant resolves the active tenant from the same context on every call; the tools map has no tenant dimension, because the tenant is part of the subject, not of the tool.
  • store is the ApprovalStore. approvers is either { roles } (approver must hold one of these roles in the PermDock policy) or a function (responder, request) => boolean. It is an extra restriction on top of grant.approval.by; both must pass. The actor rule (an agent never approves its own call) is enforced regardless.

Subject from the session

Eve exposes two authenticated principals on ctx.session.auth: initiator, who created the session, and current, who sent this turn. Each has principalId, principalType, authenticator and attributes. permdock/eve maps them:

PermDock subject partEve sourceNotes
principalsession.auth.initiatorThe human the session acts for; roles come from policy.subject or attributes you declare as server-set
actorsession.auth.current when it is not the initiator, else the Eve app principalkind: 'eve'; a schedule-dispatched turn (authenticator: 'app', principalId: 'eve:app') is an actor with no human current
delegationThe delegation option, resolved from the same contextNo default. Every eve turn has an actor, so without delegation every tool is denied with reason no-delegation (delegation)

The subject and actor options override these defaults when an application encodes identity differently. Model output, toolInput and attributes that Eve marks as user-supplied never build a subject (authentication).

Request lifecycle

  1. Eve is about to execute a tool and calls approval.request with the session and toolInput.
  2. The adapter validates toolInput against the resource schema when a data resolver exists, loads the resource and calls decide.
  3. The outcome maps to Eve's vocabulary:
Decision outcomeapproval.request returnsEve behaviour
granted"not-applicable"Tool runs without a prompt
approval-required"user-approval"Session parks at session.waiting; input.requested emitted; an ApprovalRequest with token is written to the store
denied{ type: "denied", reason }Tool does not run; the model receives reason built from denials and alternatives
unmapped tool, invalid input, thrown resolver{ type: "denied", reason }Fail closed
  1. A person presses Approve or Cancel on a channel. Eve calls approval.response with response.decision and the authenticated response.principal (the responder). The adapter finds the record for the call (from this instance's bounded session cache, or by recomputing the token from the request and the session initiator, so any replica can answer), refuses a responder whose principalId is the request's actor, applies approvers, maps the responder through the same subject as an initiator so grant.approval.by sees their roles, memberships and tenant, and calls resolveApproval (approved for Approve, rejected for Cancel, so the re-check denies), returning { status: "allowed" }. Otherwise it returns { status: "rejected", reason } and Eve keeps the request pending for another responder. The initiator cannot approve their own request unless the grant sets approval: { distinct: false }.
  2. Eve re-runs approval.request for the same call before execute (the AI SDK re-check) and runs the tool unless the answer is a denial. The adapter recomputes the token: an approved record is consumed and the answer is "not-applicable", so the tool runs once; a record still pending means nobody approved and the answer is a denial with approval-pending; a consumed, rejected or expired record is a denial too.
  3. Every step emits on('decision'); the ask, the answer and the resumed decision share token.

"not-applicable" is used only for granted. It is Eve's word for "continue", and the adapter never reaches it without a positive decision, so the fail-open path @ai-sdk/policy-opa has does not exist here.

What it validates

  • toolInput against the resource's Standard Schema when data is declared; Eve's toolInput may be undefined, which is a validation failure and therefore a denial.
  • The subject comes from session.auth, never from toolInput or the model.
  • The responder in approval.response is Eve's authenticated principal; it must not be the actor, must satisfy approvers, and its verdict is recorded in the store.
  • Replays: the token is recomputed on resume. Eve's approvedTools (session-scoped sticky approvals) is never consulted, and once()-style reuse is not offered; each call has its own request, which is also what Eve's docs recommend for non-idempotent side effects across replays.

How denials surface

  • { type: "denied", reason } with reason built from the Decision: the failing roles, the reason kind and the alternatives list, phrased for the model ("charge.refund denied: member (condition). Alternatives: charge.read.").
  • A rejected responder gets { status: "rejected", reason: "approver is the actor of this request" } (or the approvers reason); the request stays pending, matching Eve's semantics.
  • Expired or mismatched approvals on resume are denials with detail approval-expired or approval-mismatch.

Example app

apps/examples/eve-agent: HTTP harness on 127.0.0.1:3474 with GET /health, driving the pair with Eve's session shapes. GET /list_posts returns not-applicable. GET /delete_post?call=… returns user-approval; POST /approve?call=… answers as an admin reviewer; the next GET /delete_post for that call is the re-check and returns not-applicable once, and a further one is denied. No model API key. tests/integration also runs the pair through the toolApproval Eve 0.58 builds in its harness, inside a multi-step AI SDK streamText loop against a Postgres ApprovalStore. This example is also the deploy template for the PermDock Cloud listing on the Vercel Marketplace: swapping memoryApprovalStore() for cloud().approvals is the only change (Cloud adapter).

Last updated on

On this page