Divvent

For Divvent’s own team. It is generated from the operations document in our repository, which is the one to edit. If you use Divvent, the help centre is for you.

6b. Agent access, and the one thing not to do about egress

divvent agent login --env staging mints a GitHub App installation token beside the agent and exchanges it on the agent surface — https://agent.divvent.dev, or https://pr-<n>-agent.divvent.dev for a preview, a hostname on the console Worker that no Access application covers — for a one-hour platform token (docs/proposals/22-agent-access.md). It used to exchange at the console's own hostname, which is behind Access, and got Access's login page. The App's private key lives in the agent's environment as DIVVENT_AGENT_PRIVATE_KEY; the control plane never holds one and never signs.

What an agent can do with the token is read (POST /agent/fn/<name>: agentFleet, agentFleetHealth, agentFaults, agentFaultCounts, agentDeploys, agentAudit, agentCacheWarmer, agentSelf) and ask (requestAction with { function, input, reason }, for one of approveChange, declineChange, openTrial, closeTrial, decideOnCandidate). It is not an operator and runs nothing as itself; every call it makes is an audit row spelled agent:github:<login> or agent:github-app:<slug>. The surface is rate-limited per address and per session (429), and answers 404 to everything else. **Both the functions and the exchange read Authorization: Bearer <token> exactly** — that case, one space (bearerFrom, since hardening step 4). Any other spelling is no token at all: a function call answers 401, recorded once a window as agent.refused "no agent token"; an exchange with no GitHub token in its body either is a 403, "no GitHub token was presented".

Approving what an agent asked for — Ant, in the console

  1. Open Requests in the console (/requests on console.divvent.dev; /agent-requests redirects there — the agent's requests are one queue with the customers' since decision 57), behind Access as every other page; staging and previews only).
  2. Each pending request shows the function, who asked, the change it targets as it is now (for approveChange and declineChange), for a decline the note exactly as the customer will read it, labelled as written by the agent, the input exactly as it will run and its sha256 — and last, in a box of its own, the agent's reason, labelled agent-authored and not verified, capped at 500 characters, as plain text (nothing in it is a link or a button). Read the reason as a claim, not as the input: the input block is what runs.
  3. Approve and run as me runs that stored input as you, once; the function's own audit row is yours, and a second row reads <function> requested by agent:…, approved by operator:you. Decline ends it. Either is final: a request is decided once, a replay or a second click is refused, and a request expires unapproved after an hour. If the input was changed after you loaded the page, the approval is refused rather than run — and so is an approveChange whose change was replaced after the agent asked about it.

Who may approve. On staging and previews, you may approve your own agent's request — there is nobody else yet. In production the approver must be a second person (decided by Ant, 2026-09-24): the operator an agent's identity is linked to cannot approve its requests, and an identity linked to nobody cannot have a request approved at all. The App's identity is linked to nobody. Each operator links their own agent: linkAgentOwner({ identity: '<github login>' }) links it to your Access email — there is no way to name anybody else's — an operator function on the console's /fn/ behind Access (there is no screen for it yet), with agentOwners and unlinkAgentOwner beside it; none of the three can be called or requested by an agent. Link before the agent files anything: production reads the link as it stood when a request was filed, and refuses an approval by whoever set it. Nothing reads the link until production agent mode exists — below — but it belongs set before the day it does.

The edge, reconciled: rate limits and the sign-up widget

Nothing here is set by hand any more (Ant, 2026-09-27). edgeRules and turnstile in infra/control-plane.json declare the edge, and the deployer reconciles it (packages/deployer/src/edge.ts) on the runs staging.yml and production.yml already make:

The one change by hand: two rows on the deploy token, in each account's token (staging's in "Divvent — staging", production's in "Divvent — production"), in Cloudflare's token editor. Names are Cloudflare's API permissions reference's (read 2026-09-27, not yet off the editor):

  1. Zone → Zone WAF → Edit (API: Zone WAF Write), on the zone the environment serves: divvent.dev on staging's token, divvent.co.uk on production's. It covers reading the entrypoint too.
  2. Account → Turnstile → Edit (API: Turnstile Sites Write), on the account.

Nothing wider: not Account Rulesets, not Zone Settings. Then check it from a terminal holding the token:

divvent control-plane token --env staging

which asks the Turnstile API once and each zone's rate-limiting entrypoint once per zone the token can see, and prints allowed or refused per row.

Until the rows are added, nothing fails. The Worker bindings are on every door either way — they need no permission beyond Workers Scripts. The edge stage prints a refused line for every rule, naming Zone → Zone WAF: Edit for divvent.dev and ending covered by the Worker binding only. The Turnstile step prints refused turnstile divvent-staging-signup, naming Account → Turnstile: Edit; the portal is uploaded with no site key, and sign-up stays closed and says so. The next deploy after the rows are added creates the widget and the rules.

Rules set by hand before this (the agent hostname's, the browser-fault doors', telemetry's) are not the deployer's: they have no divvent: ref, so they are left alone and take the plan's slots. The log names each by its description. Delete them in Security → WAF → Rate limiting rules once the deployer's are in, or the deployer's lower-priority rules will not fit.

What an agent can never ask for, whoever approves — credentials, break-glass, agent sessions, the warmer owner, change mode and new accounts, and the approval functions themselves — is AGENT_NEVER in packages/api/src/functions/agent.ts, with the reason beside each.

Tenant request rates: per tenant, per route class, by plan

A tenant application's own doors are counted too (docs/proposals/47-backup-scale.md §2), with nothing to set by hand. Every compiled application asks rateLimited before anything else; every deploy through --via carries the account's plan (ratePlanFor), and the deployer gives the Worker five Cloudflare rate-limiting bindings — DIVVENT_RATE_READ, _WRITE, _AUTH, _PUBLIC and _TENANT, namespaces 5101–5105 — at that plan's numbers, and DIVVENT_REQUEST_RATES with the scope every key begins with (<company>/<application>/<target>). So:

A customer who says a real person was refused: the answer is a 429 with retry-after: 60, and their plan's row in the table is what they met. A higher plan, or — for a class of traffic the table does not fit — a decision recorded in the proposal, not a hand-edited binding.

Production agent mode — off, and why it cannot be turned on yet

agentMode.prod in infra/control-plane.json is off, and the deploy refuses any other value, naming why. The production mode that was asked for — agents file requests only, each approval carrying the approving operator's fresh second factor — needs an operator step-up the console does not have: an operator there is an Access session with a Workspace identity on a WARP device, and nothing verifies a second factor inside an approval. Break-glass no longer takes the requester's word for one (§6f): it reads when Access last admitted them, off the verified assertion, and needs that within ten minutes. That is a proven, recent Access sign-in under an MFA policy — a real step-up signal, and one an approval could read the same way — but it is a session that is fresh, not a factor presented inside the approve request, which is the bar below; whether it is enough for agent approvals is Ant's decision, and until it is made nothing reads it there. So production has no agent hostname, its console ignores DIVVENT_AGENT_HOST, no agent function is mounted there, and the approval page is a 404.

Enabling it is two things, in order: build a verified step-up for operators on the approval (a WebAuthn assertion or a TOTP check made inside the approve request itself, not a session flag), then change agentMode.prod in a commit a reviewer sees. The deployer's refusal is the second half's guard until the first exists.

**Never set NODE_USE_ENV_PROXY=1 to get around an egress refusal.** A sandboxed agent's outbound HTTPS is an allowlist, and a host that is not on it answers 403 with x-deny-reason: host_not_allowed. The tempting fix is to route Node's fetch through the session's CONNECT proxy, and it breaks minting outright:

$ NODE_USE_ENV_PROXY=1 divvent agent login --env staging
error  agent login
  GitHub answered 403 minting an installation token for `162581217`.

api.github.com through that proxy gets the session's own GitHub credentials substituted, so the App's JWT is rejected — and the failure reads as a problem with the App, the installation or the key, none of which is true. Node's default fetch goes direct and mints correctly.

The right fix for a blocked host is to add it to the environment's network egress settings. divvent.dev and *.divvent.dev are what a session needs to reach the portal, the console and every preview; the apex and the wildcard are both required, because a wildcard does not match the bare apex. api.cloudflare.com is deliberately not on that list: the control plane holds no Cloudflare credential (DESIGN.md §10), and a session that could reach the Cloudflare API would put back the reach that design removes.

agent login distinguishes the two cases by the x-deny-reason header rather than by the status, because an egress refusal and a 403 from the exchange have nothing in common and the same sentence for both sent somebody reading Cloudflare Access settings for a problem that was a line in a settings page.