carlos / platform Public

activator/edge: state the same-uid residual honestly (comments + spec §7.2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7372698a98990c68a4e7562854c1a33f955d2aa2 parent bfe3c73
4 files changed, +62 −14
  • docs/superpowers/specs/2026-08-23-scheduled-work-design.md +29 −5
  • internal/activator/activator_test.go +5 −3
  • internal/activator/admintoken.go +7 −2
  • internal/edge/purge.go +21 −4
diff --git a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
index 38aa39dfc..dc9dcf132 100644
--- a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
+++ b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md
@@ -414,11 +414,26 @@ keeps its "only when true" rule).
**Auth (decision):** every request carries `Authorization: Bearer
<CARLOS_ADMIN_TOKEN>` — the same token the tick presents, so the app already
-has it. This is what closes the gap `purge.go`'s comment documents (a
-same-uid exec sibling can compute another host's control socket path): a
-sibling does not hold the other host's token. `POST /purge` keeps its
-existing unauthenticated behaviour — changing it would break every app
-purging today, and its blast radius is already bounded.
+has it. Be precise about what that buys. The bearer closes the channel
+against **other UNIX users on the box** (already stopped by the 0600 socket
+mode) and against **any unauthenticated dialer**: reaching the socket is no
+longer sufficient, a secret is also required. It does **not** close the
+same-uid sibling gap `purge.go`'s comment documents. The token lives in
+`/data/<host>.env` at mode 0600, and 0600 is *owner*-only — the owner being
+the single uid every exec child shares with the agent (no
+`SysProcAttr.Credential` is ever set). A misbehaving exec-backed app can read
+another host's env file, take its token and drive that host's schedule verbs.
+
+What bounds that residual: a sibling holding host B's token could already
+dial B's own instance socket directly and send it any request with that
+token. The schedule verbs add *reach* — a platform-delivered wake plus a POST
+at a time and path of the attacker's choosing — but no privilege a same-uid
+sibling did not already have. The real fix is per-instance uids, i.e. the
+unit-backed/`DynamicUser` story, and it is out of scope here.
+
+`POST /purge` keeps its existing unauthenticated behaviour — changing it
+would break every app purging today, and its blast radius is already
+bounded.
The listener is constructed with the host and a token reader (closure over
`<host>.env`), so the host is fixed at construction and never taken from a
@@ -488,6 +503,15 @@ Usage printer gains one line under *Your apps*:
- **Nothing new crosses a trust boundary.** The tick is the agent calling the
instance it already spawns, authenticated with a secret only those two
hold. The control socket gets *stricter* (bearer auth on the new verbs).
+- **The bearer does not close the same-uid sibling gap** (§7.2). "A secret
+ only those two hold" is true against other UNIX users on the box, not
+ against a fellow exec child: `/data/<host>.env` is 0600 and every exec
+ child shares the agent's uid, so a sibling can read another host's token
+ and use its schedule verbs. Bounded because that sibling could already
+ drive the other instance's own socket with the same token — the verbs add
+ reach (a wake at a chosen time and path), not privilege. Per-instance uids
+ are the fix, and they are the unit-backed/`DynamicUser` story, not this
+ one.
- **A forged `schedules.json`** (a bucket writer gone bad) can POST to an
app's declared paths at most 20 schedules × once a minute. A bucket writer
can already promote arbitrary releases to that app; this adds nothing.
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index 1fbd9335f..8d771b423 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -2628,9 +2628,11 @@ func startAndReachControlSocket(t *testing.T, b *ExecBackend, host string) (cont
// The whole point of the generalised control channel: with ExecBackend.Timers
// set, the scheduled-work verbs are served on the spawned instance's control
// socket, gated on the token THIS spawn minted into <host>.env. The wrong
-// bearer is a 401, which is what closes the same-uid-sibling gap purge.go
-// documents — a sibling can compute this socket's path, but it cannot read
-// another instance's 0600 env file to learn its token.
+// bearer is a 401 — which keeps out other UNIX users on the box and any
+// unauthenticated dialer, and is all this test claims. It does NOT close the
+// same-uid-sibling gap purge.go documents: a sibling shares the agent's uid,
+// so it can read another instance's 0600 env file and present that token.
+// Per-instance uids are the fix; see purge.go for why the residual is bounded.
func TestExecBackendStartMountsScheduleVerbsOnControlSocket(t *testing.T) {
b := testExecBackend(t) // b.Cache left nil — the schedule verbs do not need one
b.Timers = &fakeTimerStore{}
diff --git a/internal/activator/admintoken.go b/internal/activator/admintoken.go
index 13ed62fb6..d1835492f 100644
--- a/internal/activator/admintoken.go
+++ b/internal/activator/admintoken.go
@@ -4,8 +4,13 @@ package activator
// instance for the things only the platform may ask (GET /api/next-due, and
// now the scheduled-work tick — spec 2026-08-23-scheduled-work-design.md
// §6.4) and what the instance presents back on its control socket, where it
-// is the bearer that closes the same-uid-sibling gap internal/edge/purge.go
-// documents. Until this file nothing on the platform minted it: Eleven's own
+// is the bearer on the schedule verbs. That bearer keeps out other UNIX users
+// on the box and any unauthenticated dialer; it does NOT close the same-uid
+// sibling gap internal/edge/purge.go documents, because 0600 on this file is
+// owner-only and every exec child shares the agent's uid — a sibling can read
+// this file and present the token. Read purge.go for why that residual is
+// bounded (no privilege a sibling lacked already) and why per-instance uids
+// are the actual fix. Until this file nothing on the platform minted it: Eleven's own
// tooling wrote it by hand, console-created instances never had one at all,
// and the next-due probe failed silently for every one of them. Minted here,
// once, on the first spawn (or seed) that finds it missing; never rotated by
diff --git a/internal/edge/purge.go b/internal/edge/purge.go
index cada89768..0b883bd06 100644
--- a/internal/edge/purge.go
+++ b/internal/edge/purge.go
@@ -72,10 +72,27 @@ type purgeRequest struct {
// today does it with a bare POST, an auth requirement would break all of
// them at once, and the blast radius above is bounded to a re-fetch. The
// verbs mount adds do NOT inherit that: schedule.Mount bearer-gates every
-// one of them on the host's own CARLOS_ADMIN_TOKEN, which a sibling that
-// can compute this path cannot read out of another instance's 0600 env
-// file. The sibling gap is closed for the new verbs and knowingly left
-// open for the old one.
+// one of them on the host's own CARLOS_ADMIN_TOKEN.
+//
+// Be precise about what that bearer buys, because the obvious reading is
+// wrong. It closes the channel against OTHER UNIX users on the box (who the
+// 0600 socket mode already stops) and against any unauthenticated dialer:
+// reaching the socket is no longer enough, you must also present a secret.
+// It does NOT close the same-uid sibling gap described above. The token
+// lives in <DataDir>/<host>.env, mode 0600 — and 0600 means OWNER-only, with
+// the owner being the one uid every exec child shares with the agent (no
+// SysProcAttr.Credential is ever set, and the data dir is that same user's).
+// A misbehaving exec-backed app can therefore read another host's env file,
+// take its token, and drive that host's schedule verbs. The gap is narrowed,
+// not closed.
+//
+// What bounds the residual: a sibling holding host B's token could ALREADY
+// dial B's own instance socket directly and send it any request it liked
+// with that token. The schedule verbs add REACH — a platform-delivered wake
+// plus a POST at a time and path of the attacker's choosing — but no
+// privilege a same-uid sibling did not already have. The real fix is
+// per-instance uids, which is the unit-backed/DynamicUser story below, and
+// it is out of scope for this channel.
//
// Unit-backed tenants (carlos-app@<host>.service, backend_unit.go) do NOT
// share this gap, or any control socket at all, because they don't share a