title: Phase 7c-c-c consumer close — airlock agent.approve verb wired in console date: 2026-07-02 status: Accepted phase: 7c-c-c (rocky-side consumer of the external airlock verb) predecessor: docs/decisions/2026-06-29-phase-7c-c-a-close.md spec: docs/specs/2026-06-29-agent-registration-v1.md plan: docs/plans/2026-06-29-phase-7c-c-a.md external: devarno-cloud/airlock#58 (agent.approve verb producer side)
Phase 7c-c-c consumer close
Shipped
devarno-cloud/airlock#58—agent.approveverb: migration0017_agent_approvals.sql, drizzleagentApprovalstable, routesrc/routes/agent-approve.ts(GET prompt + POST/decidemints 60 s Ed25519 JWT withsub=agent_id,aud=return_apex,wsp,cap; POST/revoke→ 501 stub), RBAC permorg:agents:approve(reserved, not yet enforced pending workspace-slug → org-id map), 9 vitest cases. Decision docairlock/docs/decisions/2026-07-02-agent-approve-verb.md.rocky-hq/consolePR (TBD) — consumer half. Producer gates swap the env-only approval helper for a store-backed verify + upsert flow.src/lib/agent/approval-store.ts— durable JSON store at${ROCKY_DATA_DIR}/agent-approvals.json. Row shape{agent_id, workspace_slug, capabilities, approved_at, airlock_issuer}. Rolling 5000-row cap; corrupted read →[]so a bad file never faults a producer.src/lib/agent/airlock-approve.ts—verifyApprovalToken(token, { expectedAudience })viacreateRemoteJWKSetagainstAIRLOCK_JWKS_URL(default${AIRLOCK_ISSUER_URL}/api/auth/jwks); assertsiss=airlock,aud=own origin,alg=EdDSA.buildApprovalRedirect(...)composes the airlock URL producers embed on 403.src/lib/agent/approval.ts— rewritten async. Order of precedence:requires_approval: false→ approved; store row for(agent_id, workspace_slug)→ approved; envROCKY_AGENT_APPROVED_<AGENT_ID>→ approved (break-glass); else deny withpending airlock <verb>.src/lib/agent/gate.ts—await gateAgentInvocation(registration, workspace_slug, { next? }). 403 denials now carryairlock_url; 402 denials still do not (Polar is not surfaced via airlock).- Producer routes (
ralph/runs,workspace/convene_council,sniffer/analyze,relay)awaitthe gate and forwardairlock_urlon the 403 body. src/app/api/agent/approve/callback/route.ts— middleware-gated at roleoperator. Three shapes:?token=<jwt>(verify + upsert + 302 tonext?agent_approval=approved),?denied=1&reason=<code>,?error=<code>. Absolute or protocol-relativenextparams are coerced to/agents(open-redirect guard)..env.example+console/CLAUDE.mdupdated.
- Test suite 550/550 (previously 542 pre-swap); typecheck + lint clean on the new files.
Locked decisions
1. Store fact, not JWT
The airlock JWT is a 60-second handoff receipt. Storing it would give us an expiring blob that says nothing after the first minute. Instead the callback verifies once and writes an approval FACT (agent_id, workspace_slug, capabilities, approved_at, airlock_issuer). Producers hit the local store on every invocation — no per-invocation JWKS fetch, no clock-skew failure mode. Revocation lands in a follow-up slice; today's model is "decision-of-record."
2. Env stub stays as a break-glass fallback
ROCKY_AGENT_APPROVED_<AGENT_ID> still overrides an empty store lookup. Reason: prod incidents where airlock is unreachable but a specific agent must be allowed to run should not require a store hand-edit inside the container. The env fallback:
- runs AFTER the store lookup (store is authoritative when populated);
- is documented in
.env.example; - is the same shape as in 7c-c-a so recovery runbooks written before this slice still work.
3. Store is per-workspace, not per-org
The approval store key is (agent_id, workspace_slug), not (agent_id, org_id). Rocky-side identity is the workspace slug — orgs are an airlock concern. If two workspaces in the same org both want the same agent, they each need their own approval. This is deliberate: agent identity in rocky is already namespaced by workspace (<workspace_slug>-<producer>-<worker>).
4. Callback route path /api/agent/approve/callback is stable
Airlock verifies the return URL against the handoff_consumers table (rocky.erid.tech already registered at airlock/src/db/migrations/0011_seed_rocky_erid_handoff_consumer.sql). The /api/agent/approve/callback path is now baked into every producer's buildApprovalRedirect call — treat it as a wire contract.
5. Open-redirect defense: absolute next param → /agents
An attacker who could land the operator on the callback with a malicious next=https://evil.example would use us as an open redirector after a valid approval flow. The callback normalizes next to /agents unless it is a bare-path (^/[^/]) URL. Protocol-relative (//) is treated as absolute.
6. 402 (Polar) denials do not carry airlock_url
The gate keeps the 403 (approval) and 402 (entitlement) shapes separate. Polar denials are not resolvable by an airlock round-trip; the client UI on a 402 must route to a different surface (billing / upgrade flow) when 7c-c-b lands. Conflating them would push the caller through an approval that can't help.
Consequences
- Approval flow now round-trips a real signed token; the env stub becomes the exception, not the rule.
- Producers no longer own approval state — the store is written by the callback route and read by the gate.
- HEARTH driver approval still lives on the "not gated in console" list from 7c-c-a-hearth — the hearth binary emits its own agent events and would need its own consumer of the airlock verb.
Successor
- 7c-c-b: swap the entitlement env stub for the real Polar SDK. Unchanged by this slice.
- Follow-up (unnumbered today): store TTL / revocation / operator-facing approval list surface in
/agents.