carlos / platform Public

An app token is app-wide, so one instance's code can delete a sibling instance Issue open

Claimed by claude~paul@keymail.dev 1 week ago

What happens

CARLOS_INSTANCE_TOKEN is bound to (account, app), never to an instance, and it is delivered through the app's default config bundle — handleAPIInstanceToken says so itself (internal/console/apiinstances.go:1368):

> Delivery to the app is the config-env layer, like any other app secret — > carlos secrets set -app <app> CARLOS_INSTANCE_TOKEN=....

So every instance of a multi-tenant app renders the same token, and a kind='app' row resolves to Role: svcrole.Instance (internal/console/svcprincipal.go:124). The Instance role reaches create, GET, PATCH, DELETE and the four move doors (internal/console/authcli.go:612-628), and apiAccountAppBy scopes a service principal to its app, not to a host (internal/console/apicli.go:70).

For an app that runs one instance per customer — titogo does, which is what discussion #148 is about — that means code running in customer A's instance can DELETE /api/cli/apps/{account}/{app}/instances/{hostB}, repoint B's upstreams with PATCH, or start a residence move on B. The byte-identical 404 that protects a stranger does not apply, because every host of the app belongs to the caller's app.

This is already known, in part

The comment above the move doors (authcli.go:614-619) says it:

> requested by the tenant's ROUTER with the app-held instance token > (titogo's residence design — never by an instance about itself... > which the role system cannot yet express; the Instance role is the > same authority the create and delete doors already grant this > credential).

So the gap is recorded for the move doors. What is not recorded is that the same reasoning makes delete and PATCH reachable from any instance of the app, which for a per-customer app is cross-tenant.

What would fix it

Tokens bound to (account, app, instance): a new cli_tokens kind so a rolled-back console refuses them rather than widening them to app scope, an Instance field on Principal, and a host comparison in instanceGate rather than in apiAccountAppBy, whose {host} is an alias hostname on the domains doors and not an instance at all. The delivery path has to change too, since a token delivered through the app-wide bundle is app-wide whatever it says on it — the per-instance config layer proposed for #148 is what would carry it.

Context

Found while designing #147 and #148. The tenant-email design deliberately does not depend on this: a central hub does the onboarding there, so the email doors take a narrow role and customer instances never call them. This is filed as its own work, sequenced after #148.

Taken on branch instance-bound-tokens-155. Paul's rulings, 2026-10-16:

  1. Delivery uses the same name, CARLOS_INSTANCE_TOKEN, written by the platform into the instance config layer, opt-in per app, so it shadows the app-wide token inside each instance.
  2. An instance-bound token reaches only its own host: GET and PATCH itself, its own move status, and its own update settings within instance_control. Every other host gets the stranger's 404.
  3. Delivery is refused for a route that shares a uid with a sibling.
  4. Two halves. Half 1, which this branch builds, covers the token kind, the own-host gate, in-app update settings writes, and a person-only mint door and CLI verb. Nothing is delivered automatically. Half 2 adds per-app delivery, gated on plan 5's RouteUIDs report and on #126 giving exec instances their own uids.

Why ruling 3 forces the split: every instance the console creates runs as an exec child under the agent's uid. So until #126, a refuse-on-shared-uid gate would refuse every per-customer instance. Half 1 on its own does not close this hole. An app whose default bundle carries the app-wide token stays exposed until half 2 lets it stop.

Spec: docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md.