activator/edge: state the same-uid residual honestly (comments + spec §7.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+62
−14
docs/superpowers/specs/2026-08-23-scheduled-work-design.md+29 −5internal/activator/activator_test.go+5 −3internal/activator/admintoken.go+7 −2internal/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 |