Skip to content
bucker

Agent governance

Humans, AI agents and service accounts share one Principal spine. There is no parallel "bot user" table, no second permission system, and no place where an agent's authority is decided by different code from a human's.

That is the design commitment: an agent acting with no visible human behind it is how the Drift breach stayed invisible for weeks.

Identity

An agent has:

  • a Principal row, created in the same transaction as its identity — a half-created agent would be a principal with no governance attached;
  • a mandatory sponsor, who must be an active member of the org. Agents never outlive their sponsor's authority: offboarding a sponsor suspends their agents pending transfer, so orphaned agents stop acting immediately;
  • an OrgMembership, capped at MEMBER. An agent is never OWNER, ADMIN or BILLING, and an agent with no membership can do literally nothing;
  • a maxTier (below), validated on grant rather than by convention;
  • a lifecycle: ACTIVE / SUSPENDED / RETIRED.

Capability tiers

telemetry:read  <  context:hydrate  <  sandbox:execute  <  pr:propose  <  pr:merge

pr:merge is human-only and ungrantable to an agent at any level. It is rejected by assertGrantableTier() at grant time, and again by the approval engine at use time, and the approval engine's check runs before any organization policy is loaded. No configuration change makes it possible.

Delegation

A human's access token is exchanged down to a narrow, audience-bound, short-lived agent token carrying nested RFC 8693 act claims. The point is that every downstream action renders as:

Claude Code, acting for alice@acme, incident #123

Effective permissions = requested ∩ delegating user's ∩ agent's allowed. All three legs are enforced independently. The delegating user's leg is itself the intersection of their memberships and the scopes on the token they presented, so a user driving the API with a narrowed token cannot delegate authority that token does not carry.

Blast radius

An incident-scoped credential is bound to one incident. A global request hook resolves what each request is reaching for — an issue, or a whole project — and refuses anything outside that incident. It is a hook rather than a per-route guard because a blast radius that has to be remembered leaks the first time someone adds an endpoint.

Approvals: deny–ask–allow

Every governed action resolves to ALLOW, ASK or DENY. Four gates run before organization policy is consulted, and no policy row reaches past them:

  1. Human-only actions (pr.merge, fix.approve, anything *.merge) are DENIED for every non-human principal.
  2. Organization.aiEnabled === false denies every non-human action. A kill switch that policy can override is not a kill switch.
  3. Organization.agentPrCreationEnabled === false denies pr.propose.
  4. A SUSPENDED or RETIRED agent is denied.

Risk classes map to defaults: SAFE/ELEVATED → ALLOW, SENSITIVE → ASK, CRITICAL → DENY. An unknown action is SENSITIVE, so a new action is gated by default rather than by remembering to classify it. The risk table has a null prototype, so an agent-supplied action named toString cannot inherit its way to a lookup hit.

The sole-approver rule

The human who initiated an agent run may not be the sole approver of its output. Enforced in three places:

  • the delegation chain is persisted on the request, and re-derived from Delegation rows for agent requesters that carried no act claim, so it cannot be dodged by minting a plain agent token;
  • votes live in their own table with @@unique(requestId, voterPrincipalId), so "distinct approvers" is a database constraint rather than an application count;
  • a request is APPROVED only when it has minApprovers votes and at least one vote from outside the delegation chain. The initiator may vote; they may not be alone.

minApprovers is frozen from the policy at creation time, so a mid-flight policy edit cannot lower the bar for a request already in the queue.

Budgets and the spend meter

Budgets key on the (agent, on-behalf-of user, incident) tuple — the LiteLLM virtual-key pattern. A single spend is charged to every budget that covers it: an org-wide cap for the agent, a cap for what it may spend on one person's behalf, and a cap for one incident all apply at once, and the tightest wins.

Charging only the most specific budget would let a loop that keeps inventing new incident ids escape the agent-wide ceiling entirely.

Audit

Every decision — approve, reject, expire, and refused attempts — writes an AuditLog row. Action granularity is the point: "this agent had approval rights" is not an audit trail; "this human approved that PR at that time" is.

Governance-relevant non-events are audited too. If an approval request was opened and no channel was configured to notify anyone, that is recorded, and "no channel exists" and "Slack returned 500" are recorded as different facts rather than blurred into one word.

Where authorization lives

One module, in two halves: the engine is domain/src/modules/authz and the Fastify preHandlers that call it at a route boundary are api/src/modules/authz/guards.ts and request-scope.ts (roadmap P5.4 — nothing but the request plumbing stayed behind). Relations (read, write, admin, triage, resolve, invite, manage_agents, propose_fix, approve_fix, deploy_hotfix, manage_billing) resolve through it for every principal kind. No route reimplements a permission check; the approval engine consults authz's own agent gate rather than mirroring it, so a gate added there takes effect everywhere without a second edit.

Not built

  • No autonomous merge, at any trust level.
  • Cross-tenant agent reputation or shared trust scores do not exist.