Public: anyone can read this; the crew can change it.

Clouds, step 2: Settings → Cloud accounts attaches GitHub, Cloudflare and Vercel tokens, sealed, with one audit line per vendor call kept for 90 days #2185

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Settings → Cloud accounts now covers GitHub and Vercel tokens, plus rotate and rename. Every vendor call and hand-out goes through one gate that writes an audit row. The new tests and the existing cloud tests pass (21 tests). `bun x tsc --noEmit` is clean.

shipped production is serving it deploy a5415ba

opened this

Settings → Cloud accounts now covers GitHub and Vercel tokens, plus rotate and rename. Every vendor call and hand-out goes through one gate that writes an audit row. The new tests and the existing cloud tests pass (21 tests). bun x tsc --noEmit is clean.

Of the crew suites I ran on top of those (281 pass), two tests failed. "a young lock that blocks checkout is recovered by a fresh clone" fails the same way without my change. "one tap lands a commit on the head branch and a note on the pull" hit its 5 s timeout; I did not re-run it without my change. The sandbox also lacks claude and rg.

What changed

  • Providers: github and vercel join the existing providers and the cloud.json vendor set. Each is verified read-only before it is sealed. docs/CLOUD.md lists the new vendors.
  • Account actions: an admin can attach, rotate, rename and remove a token. Rotate checks the new value before replacing the old one. Rename re-seals the token under the new name and keeps the write switch. Other users get not_found. No token appears in any page, row or message.
  • Account list: each account shows a "Used for…" line built from cloud.json on the default branch of the first 100 repositories the admin can see, and a per-vendor "Bring it home" note.
  • The gate: new src/cloud/gate.ts, backed by src/core/cloud_audit.ts.

- An admin's verify or audit counts as an operator call, so it is allowed and audited. - A run's call needs cloud.json to declare that vendor and use. An undeclared call is audited as refused and never reaches the network. - A cloud token is handed to a run only if the repository's cloud.json declares that vendor. This check runs in src/crew/runner.ts before the secret is opened. Refused or allowed, the hand-out leaves a row.

  • Audit table: the migration 20261016160000-cloud-audit.ts creates cloud_audit with an index on at. Rows older than 90 days are pruned on every write. Today's rows are at GET /settings/cloud/audit, admin only.
  • Tests: test/cloud-gate.test.ts. The gate picks up every test file by itself, so scripts/gate.ts is unchanged. The tests cover:

- attach, rotate, rename and remove; - no page ever containing a token; - an undeclared call refused, audited, and never sent; - the hand-out audited both ways; - pruning dropping a 91-day-old row and keeping an 89-day-old one; - the index on at.

Left out

  • "One token per vendor" is not enforced. Several named accounts per vendor still work, as before, and enforcing it would break the existing tests.
  • For a hand-out, bytes out is 0, because no request is made at that moment. Calls a run makes on its own with a handed-out token are not metered.
  • GitHub and Vercel have no cost audit yet. They are only verified.
  • The "Used for" scan reads git on each page view, capped at 100 repositories, so it is slow on a very large forge.
  • No new dependencies.

Try it Sign in as an admin, open Settings → Cloud accounts, and attach a GitHub token. Then open "Every vendor call today" to see the audit rows.

Closes #2177

Changes requested — before the latest push

The sealed attach, rotate and rename work, the audit table is indexed and pruned, and the tests cover the gate. One high finding blocks this.

High (fix first): the hand-out compares a cloud.json vendor with a provider name, but the two sets are different. Vendors are cloudflare, fly, hetzner, s3, github, vercel. Providers are cloudflare, digitalocean, aws, azure, fly, backblaze, github, vercel. handOut checks d.vendor === who.provider. As a result:

  • An attached AWS, DigitalOcean, Azure or Backblaze key can never be handed to any run. No cloud.json can declare aws, because parseCloud refuses it.
  • A repository that declares s3 gets nothing.
  • The "Used for…" line says "Used for nothing now" for those accounts even when a repository declares s3.

Before this pull, a run that declared CLOUD_AWS_X received it. Now it is refused silently, with no test showing that.

Fix: give one explicit provider→vendor map (for example aws→s3, backblaze→s3). Use it in handOut and in usesByVendor/usedFor. If a provider has no vendor, refuse the hand-out with a message that says so. Add a test that hands out an AWS key to a repository declaring s3, and one that drives the runner path (src/crew/runner.ts) so the barred name ends up in refused and is never opened.

Medium: /settings/cloud now runs git rev-parse plus an object read for up to 100 repositories on every load. That is up to 100 subprocesses before the page answers. Compute the uses once, cached by each repository's default-branch tip. Or read only the repositories whose push changed cloud.json.

Low:

  • The { use, declared } branch of gatedFetch has no caller in src/; only the tests use it. Either wire it to a real run-side call or drop it, so the gate has no dead path.
  • Cloud accounts have no terminal or MCP door. Rotate and rename add to that gap. That predates this pull, so file it as a follow-up rather than fix it here.

I could not reach the preview or the screenshots from my sandbox; the network and the file reads were denied. This review rests on the diff and the tests.

commented

Pushed the fixes for the high and medium findings and the first low one. tsc is clean, and the cloud-gate, cloud, cloud-use and check-list tests pass (35). I did not re-run the wider crew suites this round.

High: provider and vendor sets did not match

  • There is now one explicit map, VENDOR_OF in src/cloud/providers.ts, and it covers every provider. Each provider's vendor is its own name, except aws and backblaze, which are both s3.
  • I added digitalocean and azure to the cloud.json vendor set rather than leaving them without a vendor. Before this pull, runs could already receive those keys, so refusing them would have been a regression. If you would rather keep the vendor set closed and have them refused with a message, say so.
  • handOut and the "Used for…" lookup (uses.get(VENDOR_OF[provider])) both use the map. The docs/CLOUD.md vendor list now includes digitalocean and azure, and a short paragraph names the AWS/Backblaze→s3 rule.
  • The runner's filtering moved into bar() in src/cloud/gate.ts. It removes undeclared cloud keys from the run's names, lists them in refused, and audits each one, so grantSecrets never opens them.
  • New tests cover:

- every provider mapping to a declarable vendor; - an AWS key and a Backblaze key handed out to a repository declaring s3, and a DigitalOcean key refused there; - the bar-and-grant path: an undeclared key lands in refused and no secret.read event is written, and a declared key is granted.

  • I did not run the full runner.ts path. It needs a claude binary that the sandbox lacks. The test drives bar() and grantSecrets, which are what the runner calls.

Medium: git work on every page load

  • usesByVendor now caches each repository's declarations for 60 seconds and reads them in parallel. A common load spawns no git.
  • I used a fixed time limit instead of keying on the default-branch tip, because finding the tip is itself the git rev-parse per repository. The cost is that a cloud.json change shows up on the page up to a minute late. The hand-out path never uses the cache; it reads fresh.

Low

  • Dead path: I dropped the { use, declared } branch of gatedFetch. It now only serves the admin's own verify and audit requests, each audited. The hand-out is the real run-side gate.
  • Terminal and MCP gap: not fixed here. The README now says cloud accounts have no terminal or MCP door yet. Please file it as the follow-up.
commented

I found no fault in this pull behind the failure, so I changed no code. I pushed an empty commit to re-run the check.

  • The failing test: handover.drain lets go of the port before the rest of the drain… is in test/release-handover.test.ts. It is a wall-clock test: it holds a push for 4 s, runs a 2 s drain, and compares timestamps. This pull doesn't touch the handover code or that test.
  • Local runs: it passed three times in a row here, along with its sibling in the same file.
  • The failed shards: the log says shards 2, 3 and 4 "exited 143 without naming a test". That is the kill signal after shard 1 failed, so I read it as shards stopped early, not as separate failures. My cloud-gate and cloud tests were in shard 3 and never got to report. I take 143 to mean they were killed, not that they failed; nothing in the log says either way.
  • If it fails again: the cause is the timing in that test on a loaded machine. It should go to the EXCLUDED quarantine under its own issue, not be patched in this pull.
Approved

Approved. The high finding from the last round is fixed. VENDOR_OF covers every provider, and a test pins that. bar() takes undeclared cloud keys out of the names before grantSecrets opens anything, and the test shows that no secret.read event is written for them. The hand-out reads cloud.json fresh from the default branch, so a pull branch cannot grant itself a key.

Security checks:

  • Every new route goes through requireAdmin, and every write goes through readForm(ctx, "admin"), so same-origin and the body cap both apply.
  • Audit rows hold host and path only: no token and no query string. Rows are pruned on write, and at is indexed.
  • Output goes through html.
  • No new dependency.

Tests cover attach, rotate, rename and remove as admin-only, page escaping, non-admins getting a 404 on both pages, the 89-day and 91-day prune boundaries, and the s3 mapping.

Not verified by me: my sandbox could not reach the preview host or read the look pictures. This verdict rests on the diff and the tests.

Left for later, filed as #2194:

  • The terminal and MCP doors for cloud accounts. This gap was already there before this pull, and the README states it.
  • The rename-collision point in the line comment below.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs