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),
);approvalis an Eve{ request, response }pair, each taking the one context object Eve passes (EveApprovalContextforrequest,EveResponseContextforresponse).requestlooks the tool name up intools(own keys only), resolves the resource, callsdecideand maps the outcome.responsechecks the responder against the approval record and theapproversrule.approvalFor(permission, data?)builds the same pair for one tool inline.datareceives the tool input asunknown; parse it with the tool's schema before loading the resource.permdock(ctx)returns a request-scopedPermDockfromctx.sessionfor checks insideexecuteor in other Eve code. It builds a fresh frozen instance on every call, so a replayed step never sees state from an earlier one.tenantresolves the active tenant from the same context on every call; thetoolsmap has no tenant dimension, because the tenant is part of the subject, not of the tool.storeis the ApprovalStore.approversis 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 ofgrant.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 part | Eve source | Notes |
|---|---|---|
principal | session.auth.initiator | The human the session acts for; roles come from policy.subject or attributes you declare as server-set |
actor | session.auth.current when it is not the initiator, else the Eve app principal | kind: 'eve'; a schedule-dispatched turn (authenticator: 'app', principalId: 'eve:app') is an actor with no human current |
delegation | The delegation option, resolved from the same context | No 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
- Eve is about to execute a tool and calls
approval.requestwith the session andtoolInput. - The adapter validates
toolInputagainst the resource schema when adataresolver exists, loads the resource and callsdecide. - The outcome maps to Eve's vocabulary:
| Decision outcome | approval.request returns | Eve 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 |
- A person presses Approve or Cancel on a channel. Eve calls
approval.responsewithresponse.decisionand the authenticatedresponse.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 whoseprincipalIdis the request's actor, appliesapprovers, maps the responder through the samesubjectas an initiator sogrant.approval.bysees their roles, memberships and tenant, and callsresolveApproval(approvedfor Approve,rejectedfor 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 setsapproval: { distinct: false }. - Eve re-runs
approval.requestfor the same call beforeexecute(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 withapproval-pending; a consumed, rejected or expired record is a denial too. - Every step emits
on('decision'); the ask, the answer and the resumed decision sharetoken.
"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
toolInputagainst the resource's Standard Schema whendatais declared; Eve'stoolInputmay beundefined, which is a validation failure and therefore a denial.- The subject comes from
session.auth, never fromtoolInputor the model. - The responder in
approval.responseis Eve's authenticated principal; it must not be the actor, must satisfyapprovers, 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, andonce()-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 }withreasonbuilt from the Decision: the failing roles, the reason kind and thealternativeslist, 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 theapproversreason); the request stays pending, matching Eve's semantics. - Expired or mismatched approvals on resume are denials with
detailapproval-expiredorapproval-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).
Related standards
- Approvals and approvals: the token, the store, the approver rules.
- Delegation: the actor half of the subject for agent turns.
- OWASP Agentic Top 10: ASI02 tool misuse via per-tool permissions and input validation.
- AI SDK adapter: Eve's approval status vocabulary is the AI SDK 7 one.
Last updated on
Claude Agent SDK
permdock/claude-agent answers Claude Agent SDK canUseTool callbacks and PermissionRequest hooks from PermDock decisions, mapping built-in tools such as Bash to typed permissions.
OpenAI Agents SDK
permdock/openai turns PermDock decisions into OpenAI Agents SDK needsApproval predicates, guards tool lists per caller, resolves interruptions against a pluggable ApprovalStore, and binds the replay-safe token to the serialised RunState.