@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
255 lines (204 loc) • 11.6 kB
Markdown
---
name: agent-governance
description: >-
Lock down @adonis-agora/agent: the ActorResolver identity seam (AuthActorResolver over
ctx.auth.user with toActor mapper, HeaderActorResolver x-actor-id/x-actor-role behind a
gateway, AnonymousActorResolver public default — one anonymous actor per browser), DefaultToolAuthorizer role
intersection with the offered-tools + invoke-time double check, object-level ownership
via evaluateOwnership (404 for unknown ids), governanceAuthorize mounting semantics for
/agent/governance/* (404 without it) and the dashboard mount refusals,
evaluateGovernanceGate fail-closed behavior, and GET /agent/approvals/mine exemption.
Use for "tool forbidden", "governance routes 404", "dashboard won't mount", "IDOR /
cross-actor access", "swap roles for ability checks".
metadata:
type: core
library: "@adonis-agora/agent"
library_version: "0.25.2"
framework: adonisjs
sources:
- "DavideCarvalho/adonis-agent:packages/adonis/docs/governance/authorization.mdx"
- "DavideCarvalho/adonis-agent:packages/adonis/docs/governance/dashboard.mdx"
- "DavideCarvalho/adonis-agent:packages/adonis/src/governance-gate.ts"
- "DavideCarvalho/adonis-agent:packages/adonis/src/ownership.ts"
---
# Governance: identity, authorization, ownership
Authorization in `@adonis-agora/agent` works at three layers: **who is calling**
(an `ActorResolver` — anonymous per browser when none is configured, `AuthActorResolver` to
require login), **what they may do** (a
`RolesPolicy` checked twice — before the model sees a tool and again at invoke time),
and **which records they may address** (object-level ownership on every run/thread route).
## Setup
Wire identity once; everything downstream keys off it:
```ts
// config/agent.ts
import { defineConfig, AuthActorResolver } from '@adonis-agora/agent'
export default defineConfig({
model: () => aiSdkModel(openai('gpt-4o-mini')),
// Reads ctx.auth.user (populated by @adonisjs/auth). Throws when unauthenticated —
// no identity is ever invented. Map non-standard user shapes with toActor.
actorResolver: new AuthActorResolver({
toActor: (user) => ({
id: String(user.id),
roles: user.roleNames, // e.g. pulled from a relation
tenantRef: user.tenantId,
}),
}),
defaultRoles: ['STAFF'], // roles a tool requires when it declares none
// emptyRoles: 'deny', // an empty roles list reaches nobody (default 'allow': open since 0.46)
})
```
## Core patterns
### Pattern 1 — swap role checks for ability checks
`roles` and `ability` share one seam. The default policy intersects roles; an
ability-aware policy (the `@adonis-agora/authz` Bouncer adapter, `authzToolAuthorizer`)
consults each tool's `ability` instead — tenant-scoped and fail-closed. Swap only the
binding; tools keep both annotations:
```ts
// config/agent.ts
import { defineConfig } from '@adonis-agora/agent'
import { authzToolAuthorizer } from '@adonis-agora/agent/authz'
export default defineConfig({
// ...
authorizer: authzToolAuthorizer, // alias: rolesPolicy — one seam, both filter layers follow
})
```
Because the offered-tools filter calls `canOffer(actor, tool)` when the policy defines
it (else `can`) and the invoke-time re-check always calls `can(actor, tool)`, swapping
the policy changes both layers together — they disagree only when a policy defines
`canOffer` to offer what it will not run (A2A step-up).
The provider wraps whatever policy you configure with `personalAgentGate`: an actor with
the `personal_agent` role (personal agents over A2A/PACT or Poppy conversations) reaches only tools that declare one of its
roles — a tool with no `roles` is out of its reach even when open by default — and its
memory and skill scopes resolve to none. Nothing changes for any other actor.
Both personal-agent surfaces build that actor the same way: `personal_agent` plus one
`scope:<id>` role per granted scope — a PACT delegation's scopes (`a2a_provider`) or a
signed-in Poppy Session Token's (`poppy_provider`: `scope:poppy:read`, `scope:poppy:write`,
custom scopes). A tool behind a missing scope is offered, not run; the refused call is the
step-up (A2A: an `AUTH_REQUIRED` task; Poppy: an `authorization` event).
> **Experimental:** Poppy (Personal Agent Protocol) is a Draft 0.1 spec still in development
> (https://personalagentprotocol.org/docs/spec). The Poppy surface implements Draft 0.1 and WILL change
> as the spec evolves — possibly breaking, outside semver majors while it is a draft.
Source: `packages/adonis/docs/governance/authorization.mdx` ("Ability-aware
authorization"), `packages/adonis/src/authorizer.ts`,
`packages/adonis/src/a2a/gate.ts` (`personalAgentGate`, `personalAgentScopes`),
`packages/adonis/docs/a2a.mdx` ("What a personal agent can reach"),
`packages/adonis/docs/poppy.mdx` ("Scopes, sign-in and actions").
### Pattern 2 — mount the governance read-model with `governanceAuthorize`
The cross-actor `/agent/governance/*` read routes exist ONLY when a gate is configured.
Omit it and every one of those paths answers 404 — by design, so an ungated read-model of
every actor's spend/threads/approvals can never be served by accident:
```ts
// config/agent.ts — typically an ADMIN check; runs AFTER the actor resolves.
export default defineConfig({
// ...
governanceAuthorize: (actor, _ctx) => actor.roles?.includes('ADMIN') ?? false,
})
```
`evaluateGovernanceGate` applies it fail-closed: falsy → 403, thrown → 403 (message shown
only outside production). The SAME predicate feeds object-level ownership's `privileged`
flag, and the dashboard console refuses to mount without it.
Source: `packages/adonis/docs/governance/authorization.mdx` ("Governance route
authorization"), `packages/adonis/src/governance-gate.ts`.
### Pattern 3 — unit-test your gates router-free
Both decision cores are exported pure functions, so you can test them without booting an
app:
```ts
import { evaluateOwnership, evaluateGovernanceGate } from '@adonis-agora/agent'
evaluateOwnership('u_1', 'u_1', false) // → { ok: true, status: 200 } owner
evaluateOwnership('u_2', 'u_1', false) // → { ok: false, status: 403 } not owner
evaluateOwnership('u_2', null, false) // → { ok: false, status: 404 } unknown id
evaluateOwnership('u_2', 'u_1', true) // → { ok: true, status: 200 } privileged
const verdict = await evaluateGovernanceGate(actor, ctx, config.governanceAuthorize, !app.inProduction)
if (!verdict.ok) return ctx.response.status(verdict.status).json({ error: verdict.error })
```
Unknown ids deliberately reply 404, never 403, so a response never confirms to a caller
that an id they don't own exists.
Source: `packages/adonis/docs/governance/authorization.mdx` ("Object-level ownership"),
`packages/adonis/src/ownership.ts`.
## Common mistakes
### CRITICAL — assuming `HeaderActorResolver` is production-safe
```ts
// Wrong — anyone can send x-actor-id: u_admin and become admin unless a gateway strips these.
actorResolver: new HeaderActorResolver()
```
```ts
// Correct — read a VERIFIED principal in production; headers are for dev/gateway use only.
actorResolver: new AuthActorResolver()
```
Mechanism: `HeaderActorResolver` trusts client-sent `x-actor-id` / `x-actor-role` /
`x-tenant-ref`; it is safe only behind a gateway that strips and re-sets them from an
authenticated principal. It does throw when `x-actor-id` is missing, but any caller who
sets the header IS that actor.
Source: `packages/adonis/src/actor-resolver.ts` (`HeaderActorResolver` security note),
`packages/adonis/docs/governance/authorization.mdx`.
### HIGH — expecting `/agent/governance/*` to exist after configuring only `governanceQueries`
```ts
// Wrong — read-model resolves, but no gate → the 12 governance routes are NEVER mounted (all 404).
export default defineConfig({ store: 'lucid', stores: { lucid: stores.lucid() } })
```
```ts
// Correct — set the gate to mount them (gated); or explicitly opt into open reads.
export default defineConfig({
...,
governanceAuthorize: (actor) => actor.roles?.includes('ADMIN') ?? false,
// governanceAuthorize: () => true, // deliberate legacy behaviour: ANY authenticated actor reads
})
```
Mechanism: mounting is conditional on the gate because the data is cross-actor; a boot
warning names both ways forward when the read-model resolved without a gate. Note
`GET /agent/approvals/mine` is exempt — always mounted when the read-model resolves, and
always scoped to the calling actor.
Source: `packages/adonis/src/define_config.ts` (`AgentConfig.governanceAuthorize`),
`packages/adonis/docs/streaming-and-http.mdx` ("Five optional surfaces").
### HIGH — treating `dashboard.authorize` as the dashboard's data gate
```ts
// Wrong — authorize gates only the SPA shell; panels still 403/404 without governanceAuthorize.
dashboard: { enabled: true, authorize: (actor) => actor.roles?.includes('ADMIN') ?? false }
```
```ts
// Correct — one predicate shape on BOTH knobs: shell gate + data gate.
governanceAuthorize: (actor) => actor.roles?.includes('ADMIN') ?? false,
dashboard: { enabled: true, authorize: (actor) => actor.roles?.includes('ADMIN') ?? false },
```
Mechanism: the console is a pure consumer of `/agent/governance/*`; `authorize` decides
who may load the shell while `governanceAuthorize` decides whether the console mounts at
all and whether its data routes answer. With no gate the provider logs a boot warning and
refuses to mount rather than serve eight dead panels.
Source: `packages/adonis/src/dashboard/define_config.ts` (`decideDashboardMount`,
`NO_GOVERNANCE_GATE_WARNING`).
### MEDIUM — giving a per-agent resolver a different identity than the global one
```ts
// Wrong — webhook agents resolve actor 'body:svc-1'; the global resolver would say 'user_9'.
agents: [{ name: 'webhook', actorResolver: new BodyActorResolver() }]
```
```ts
// Correct — a per-agent resolver must yield an id the GLOBAL resolver could also produce.
agents: [
{
name: 'webhook',
actorResolver: new AuthActorResolver({ toActor: mapServicePrincipal }), // same id space
},
]
```
Mechanism: the override applies ONLY to `POST /agent/chat`; stream re-attach, cancel,
approve/reject, threads, quota, and all governance routes resolve the GLOBAL resolver —
a mismatched `actor.id` means the caller starts a run it cannot stream, cancel, or
approve (ownership compares ids).
Source: `packages/adonis/docs/authoring/personas-and-agents.mdx` ("The override applies
to POST /agent/chat only").
### MEDIUM — designing a client around a thrown gate's message
```ts
// Wrong — reading error.message from a 403 to branch UI logic; prod never carries it.
if (err.message.includes('missing tenant')) showTenantPicker()
```
```ts
// Correct — treat 403 as opaque; debug details exist only outside production.
if (err.status === 403) showForbidden() // body is exactly { "error": "forbidden" } in prod
```
Mechanism: `evaluateGovernanceGate` passes the thrown message through only when `debug`
is true (the provider passes `!app.inProduction`); in production the body is the generic
string, because authorization predicates routinely throw with sensitive detail.
Source: `packages/adonis/src/governance-gate.ts`, `packages/adonis/docs/governance/authorization.mdx`
("A thrown message never reaches production clients").
See also: `agent-setup/SKILL.md` — wiring `AuthActorResolver` at setup time;
`agent-console-client/SKILL.md` — how the console consumes these gated routes.