Skip to content

gateway: phase-6 cap walk (PIPELINE 2) behind enforce_budgets - #1684

Merged
Evanfeenstra merged 1 commit into
mainfrom
gateway-cap-walk
Sep 14, 2026
Merged

Evanfeenstra merged 1 commit into
mainfrom
gateway-cap-walk

Conversation

@Evanfeenstra

Copy link
Copy Markdown
Collaborator

Stacked on #1683 (kill switches) — base is gateway-kill-switches; retarget to main once that merges.

What

CheckCaps (internal/auth/capwalk.go) is phase-6 PIPELINE 2: one pipelined Redis read of the accumulators PostLLMHook already writes, then a fixed-order comparison:

Order Counter Cap Code
1 cost:run:<r> per chain layer, leaf first layer max_cost_usd run_cost_exceeded
2 cost:ua:<nonce> realm_budgets[<this realm>].max_total_usd (phase 11) realm_budget_exceeded
3 cost:ua:<nonce> ua.budget.max_total_usd ua_budget_exceeded
4 steps:run:<r> per chain layer layer max_steps run_step_exceeded
5 cost:agent:<a>:<bucket> agent_budgets cap agent_cost_exceeded
  • >= comparisons on past spend; one call of overshoot is accepted per the plan.
  • Steps are walked over every layer, not just the leaf — strictly stronger than the plan text, matches what the accumulator increments.
  • agent_budgets entry with cap_usd: 0 blocks the agent outright (the plan's "permanent kill").
  • Redis error ⇒ 402 budget_check_unavailable (fail closed); Redis unconfigured ⇒ no-op.

Gating

New enforce_budgets flag (config key or BIFROST_PLUGIN_ENFORCE_BUDGETS, same grammar/source= reporting as #1674). Effective only with enforce_macaroons=true — otherwise budgets are bypassable by dropping the macaroon, so it logs a warning and stays in shadow.

Shadow (default): over-cap calls log
auth: budget shadow code=run_cost_exceeded … detail="run r_x spent $5.0100 of its $5.00 cap"
and pass through with claims stamped, so the accumulator keeps counting. That line is what to grep before flipping the flag.

Boot line now reports both flags plus the effective value.

Accumulator change

cost:ua is now also written when the UA carries a realm_budgets cap for this swarm's realm (not only an org-wide max_total_usd) — the cap walk reads the same counter for both.

Not in this PR

Tool-loop detection, hard_ceiling, user_id == customer_id cross-check, /_plugin/config/* overrides. Sphinx-swarm plumbing for BIFROST_PLUGIN_ENFORCE_BUDGETS (like swarm#757 did for macaroons).

Tests

21 new miniredis tests: each rejection code, ancestor-caps-child, realm vs UA precedence, realm-cap-only UA read, zero-cap block, zero caveats = uncapped, order, fail-closed, namespace; plus Evaluate/ApplyToLLMPre in enforce / shadow / budgets-without-macaroons; config + env override; accumulator realm-cap write.

CheckCaps reads the accumulators PostLLMHook writes and rejects with
402 when a verified macaroon is over any spend cap:
- run_cost_exceeded / run_step_exceeded: every chain layer, leaf first
  (a parent's exhausted cap stops its children)
- realm_budget_exceeded (phase 11) / ua_budget_exceeded: cost:ua
- agent_cost_exceeded: agent_budgets bucket; cap_usd 0 blocks outright
- budget_check_unavailable on Redis errors (fail closed)

Gated by a new enforce_budgets flag (config key or
BIFROST_PLUGIN_ENFORCE_BUDGETS), effective only alongside
enforce_macaroons. Off (default), over-cap calls log
'auth: budget shadow …' and pass through with claims stamped so
accounting keeps running.

The accumulator now writes cost:ua when the UA carries a realm cap for
this swarm's realm, not only an org-wide max_total_usd — the cap walk
reads the same counter for both.
@Evanfeenstra
Evanfeenstra merged commit bc67ceb into main Sep 14, 2026
6 checks passed
Evanfeenstra added a commit that referenced this pull request Sep 14, 2026
)

Operator half of the phase-6 kill switches (#1683, #1684) in the admin
SPA. Scope is kill/unkill + live hot state only; no config editing,
no bulk actions, no analytics changes.

- api/types.ts: RunStateResponse, AgentStateResponse, KillRunResponse,
  KillAgentResponse mirroring hotstate.go (hand-maintained; no tygo).
- api/queries.ts: useRunState (2s in flight / 30s done / 500ms for 30s
  after a kill), useAgentState (10s), useAgentStates (30s per row),
  useKillRun / useUnkillRun / useKillAgent / useUnkillAgent. 503
  (no Redis) folds into data === null with a 60s retry.
- components/KillConfirmModal: one modal, two modes — plain confirm
  for runs, typed agent name for agents. Explains scope, TTL (1h /
  24h) and "takes effect on the next LLM call; enforced only with
  enforce_macaroons=true". No window.confirm (Hive iframe sandbox).
- components/StatusBadge: running / killed / exceeded / done with the
  derivation documented in one place.
- RunDetail: live-state card (cost, steps, last tools, kill flag,
  "state expires in") + Kill / Unkill; 503 renders as an inline note
  with the switch disabled.
- AgentDetail: killed / exceeded badge in the header + Kill / Unkill
  agent. Agents: kill-state column read from /state per row.
- icons: StopIcon.
- vite.config: bypass /_plugin/ui/* in the dev proxy so Vite serves
  the SPA instead of the gateway's embedded bundle (local edits never
  showed otherwise); GATEWAY_URL override for the proxy target.
- .claude/launch.json: gateway-ui dev-server entry.

dist/ stays the tracked placeholder — the repo doesn't commit builds.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant