Repository navigation
Description
Status: current scope is a cell-level blast radius. The earlier per-node design is kept at the bottom for history only; do not implement it.
Goal
A host compromise on one node is bounded to its cell. Ops can cut a node or a cell off without that node's cooperation. No new protocol and no new central component; cell size is the operator's knob.
Done
- S3 manifest path confinement: sandboxd: reject S3 manifests whose paths leave the fetch dir or exceed the record bounds #490 (
f26044cd).
Sandbox code
None required. Rotation without downtime is not planned: secrets rotate with a whole-cell restart, and cutoff relies on the network, not on rotation.
Deployment contract
Per cell:
- a PostgreSQL database and role that cannot reach other cells' databases (
sandboxd_tenantshas no cell column, so cells cannot share a database); - a store credential limited by the backend's policy to the cell's bucket or prefix;
- a cloud identity with no access to other cells' secrets or to IAM, network or firewall controls;
api_token,mesh.cluster_key,preview_secretand an egress root;- a
tunexit with its own netns, FDB, neighbor table and an underlay allowlist of the cell's nodes.
Per node (recommended): its own PG role and store key with the cell's grants, so one node can be cut off without restarting the rest.
Ingress: control, preview and mesh ports, PostgreSQL and the store accept only the cell's node addresses, plus the control plane where it calls in. Backends refuse the cell's credentials from anywhere else.
Anti-spoofing outside the host: VPC rules on cloud; port security or routed ports on bare metal.
Tenant tokens are not reused across cells; this is the control plane's duty.
Runbook
- Cutoff. On every host the node reaches, drop its address ahead of the established-connection rule and flush its conntrack entries; terminate its PostgreSQL sessions and disable its role and key. Done when the drop counters move, no established flow to it remains, and it is gone from
/v1/peerson every remaining node within the measured bound. - Rotation.
api_token,mesh,preview_secret,meta_store,checkpoint_storeandegress_cachange only at a restart (config/reload.go), and nodes on different values cannot talk to each other, so restart the whole cell at once. - Recovery. Treat the whole cell as suspect. Rebuild hosts from clean images, re-promote operator templates from a source outside the cell, and do not trust the cell's checkpoints automatically.
Acceptance checks
- Cell A's credentials cannot reach cell B's control API, previews, database, store or cloud resources.
- Cutoff ends established and new access within the documented bound, without the node's help.
- Copied credentials do not work from outside the cell.
- A cell A node cannot inject into or draw traffic from cell B's overlay.
- Cell B guests do not trust a cell A egress intermediate.
- A rotation drill follows the real restart requirements.
Tasks
- S3 manifest path confinement (sandboxd: reject S3 manifests whose paths leave the fetch dir or exceed the record bounds #490)
-
docs/deploy.md: values shared within a cell; the restart-only list (docs: the cell contract, cutoff, rotation and recovery runbook #491) -
docs/security.md: the cell contract (docs: the cell contract, cutoff, rotation and recovery runbook #491) - runbook: cutoff with checks, whole-cell rotation, recovery (docs: the cell contract, cutoff, rotation and recovery runbook #491)
- measure how long a cut-off node takes to leave
/v1/peers: 6.6 s on a two-node test cell, rejoin 1.0 s after the drop is lifted; grows with cell size, measure per cell - rotation without downtime: not doing; whole-cell restart
Earlier per-node design (superseded)
Goal
A guest that escapes to host root on one node loses that node's claims and
nothing else: no other node's claims, no cluster-wide credential, no way to
steer clients or peers to itself. The cluster keeps its gossip shape. No new
central service is added; every node verifies every other node locally. The
design runs on any platform: a cell of bare-metal hosts with no cloud provider
is a first-class case, and cloud attestation is an optional convenience.
Out of scope here:
- Confidentiality of the shared checkpoint store and PostgreSQL inside one
cell. A cell shares one store prefix and one database role, so a compromised
node can read its cell's checkpoints and rows. The cell is the
confidentiality boundary; see Residual risk. - Hardening the VMM itself (Firecracker runs unjailed — integrate the jailer to contain a compromised VMM cocoon#83).
What a compromised node holds today
| Credential | Scope | What it grants |
|---|---|---|
api_token |
every node | root on every node: list claims, read any claim token (GET /v1/sandboxes/{id}), exec, delete, move, register volumes |
mesh.cluster_key |
every node | join gossip and replace any node's NodeState; merge checks only the epoch, so a member can rewrite another node's addr and draw redirects, moves and the tokens they carry |
| tenant tokens | sent by clients | the SDK and API replay a tenant token to whatever node a redirect names |
| claim tokens | sent by clients and the adopter | Adopter.Adopt sends the claim token to every peer |
| checkpoint store and meta store credentials | every node | read, write and delete every object and row |
| promoted templates | the store | checkTemplateOwner runs on the writing node, so any node can replace any template, including operator templates every tenant clones |
The first four are lateral paths that need no shared storage at all. This
design removes them and makes the store tamper-evident.
Design
1. Node identity
Each node proves who it is once, at admission, with a credential that a trust
root the cell already holds can verify. Every peer checks it locally. No issuer
runs.
The key. At start the node generates an ed25519 key pair in memory. The
private key never touches disk and dies with the process; a restart is a new
key and a new admission.
The proof. The node binds that key to its identity with one of these
providers:
| Provider | Proof | Trust root in config | Online dependency |
|---|---|---|---|
x509 |
the node signs its public key with the key of a per-node certificate | the operator CA certificate | none |
gce |
an instance identity token from the metadata server (instance/service-accounts/default/identity?format=full) with audience = "sandboxd:" + base64url(sha256(pubkey)); Google signs it and names the project, zone, instance id and instance name |
Google's JWKS, cached | only to admit a node whose token uses a key id not yet cached |
x509 works on any platform and is the provider for bare metal, other clouds
and private datacenters. The certificate comes from the operator's own
provisioning (the system that installs the host), signed by an offline or
HSM-held CA; sandboxd only reads the files and reloads them when they change.
gce saves GCE hosts that step. A cell may mix both. Other cloud attestations
can be added as providers without changing the rest of the design.
The bundle. The public key plus the proof. A node serves its bundle at
GET /v1/identity.
Admission. A verifier admits a bundle when the proof verifies against a
trust root and the subject matches the cell's admission rule, for example
x509: CA C, subject matches sandbox-metal-* or
gce: project P, instance name matches sandbox-node-*. The node id is the
certificate subject or the instance name; it is never self-declared.
Continuity. The proof is checked once, at admission. After that the
verifier trusts the key for as long as the node gossips under it, whatever the
proof's own expiry. A GCE token's one-hour life or a certificate's expiry
therefore never drops a running peer; it matters only when a node restarts and
must be admitted again. Google's JWKS is cached without limit and refetched,
rate-limited, only for an unknown key id. When the trust root is unreachable,
existing members keep working for any length of time; only nodes that start
during the outage wait to be admitted.
Revocation. identity.revoked lists {node, since}. A verifier drops a
listed node's current key, refuses its admission, and rejects anything it
signed after since (§6). Expiry is not revocation: a node to be removed is
listed, not waited out.
The admission rule, trust roots and revocation list are cluster-invariant
config, covered by the existing digest. Adding a node needs no config change
when its name fits the rule.
New config:
"identity": {
"provider": "x509",
"x509": {"cert": "/path/node.pem", "key": "/path/node.key"},
"admit": [
{"kind": "x509", "ca": "/path/ca.pem", "subject": "sandbox-metal-*"},
{"kind": "gce", "project": "P", "name": "sandbox-node-*"}
],
"revoked": [{"node": "sandbox-metal-7", "since": "2026-01-01T00:00:00Z"}],
"template_signers": ["/path/operator-publish.pub"]
}A cell without a gce admission rule never contacts Google.
2. Signed gossip
NodeState gains PubKey and Sig. The origin signs each state it
publishes; relays pass it unchanged.
mergeaccepts a state for node X only whenPubKeybelongs to an admitted
key whose node id is X andSigverifies. A state that fails is dropped,
whoever relayed it.- A node fetches an unseen key's bundle once from that node's
GET /v1/identity. Push/pull carries the state, key and signature only
(about 100 bytes more per node), never the bundle. - Routing reads only verified states:
Candidates,PeerAddrs,
TemplateOwnersand the rest skip a member whose state never verified, so
a SWIM member without an identity receives no traffic. mesh.cluster_keystays optional. It hides gossip from the network but is
no longer a trust anchor; leaking it lets a host join SWIM and nothing more.
3. Peer and API authentication
With identity set, the control port serves TLS. Each node serves a
self-signed certificate over its identity key, so a client that verified the
node's bundle knows it reached that node. Callers authenticate by role:
| Role | Credential | Verified against |
|---|---|---|
node |
client certificate over an admitted identity key | the admission rule |
operator |
an OIDC ID token with aud = "sandboxd", or an X.509 client certificate |
operators: [{kind: "oidc", issuer, subjects}, {kind: "x509", ca, subjects}] |
tenant |
tenant token | sandboxd_tenants (unchanged) |
sandbox |
per-sandbox token | the claim (unchanged) |
OIDC suits a control plane that already has a workload identity (any issuer:
a cloud, Kubernetes, Dex, Keycloak). An operator X.509 certificate serves a
deployment with no OIDC issuer at all.
api_token remains for single-host deployments and is refused over the
network when identity is set. A new admin_socket (a unix socket, root-only
mode 0600) accepts operator calls without a credential, so on-host scripts keep
working: whoever can open it is already host root.
What each role may call:
| Endpoint group | operator | node | tenant | sandbox |
|---|---|---|---|---|
| claim, release, hibernate, wake, env, renew | yes, for any tenant (X-Sandbox-Tenant) |
no | own | own claim |
| exec, relay, ports, files | yes | no | no | own claim |
GET /v1/sandboxes/{id} with token |
yes | no | no | no |
| cordon, drain, pools, volumes | yes | no | no | no |
move rounds (/v1/moves/*) |
no | yes, see §5 | no | no |
| checkpoint blob, probe | no | yes | no | no |
GET /v1/sandboxes/{id}/owner |
yes | yes, without a token | own | own claim |
A node cannot exec, read tokens, or change claims on a peer.
4. Acting for a tenant
A control plane that serves many tenants calls nodes as operator and names
the tenant in X-Sandbox-Tenant. It never sends a tenant token to a node, so a
redirect to a compromised node harvests nothing. Tenant tokens stay for SDK
users who talk to nodes directly; those clients follow redirects only to nodes
whose signed state names the redirect address (§7).
5. Ownership changes
A node decides its own moves and adoptions, so its own checks cannot stop a
compromised node. Integrity of what an adopter or a move target boots comes
from §6, not from liveness evidence.
- Moves. The target admits a move only from the node that its verified
view and the move record both name as the source. A malicious source can push
only claims it already held; it cannot pull one, because a move starts at
the source. - Adoption. A two-phase offer replaces the token broadcast. The adopter
offers the claim to peers with the token's SHA-256, which the row already
stores; a peer answers whether it would take it. Only the one peer that
accepts receives the plaintext token, so refusing peers learn nothing and
claim auth keeps comparing plaintext tokens as today. The accepting peer
verifies the record chain (§6) with that token before it boots anything.
6. Checkpoint store integrity
The store and the archive rows stay shared within a cell; integrity no longer
depends on who can write them. Chunks are already addressed by SHA-256.
Checkpoints, archives and replicas are bound to the claim token. Every
record carries mac = HMAC(claim_token, record_digest), where the record names
the claim id, the manifest digest, a sequence number and the previous record's
digest. A reader verifies the MAC chain with the token it holds, from the
client's call or from the handing-over owner, before it boots anything. The
archive row in PostgreSQL becomes an untrusted pointer: a node that never held
the claim's token cannot produce a record that verifies, so it can break
availability but cannot make another claim boot its disk. The rule needs no
owner-of-record lookup and no liveness evidence, and it covers adoption after
the owner is gone. The writer also signs the manifest with its identity key,
which attributes objects for sweeps and revocation; the MAC is what readers
trust.
Templates split by who can clone them:
| Kind | Where it lives | Signed by | Rule |
|---|---|---|---|
| pool golden | each node builds its own, on local disk | — | unchanged; never shared |
| operator template (empty tenant, every tenant may clone it) | shared store | the promoting node and a key in identity.template_signers |
a promote stages it; it is invisible to other nodes until an operator signs the key and content digest (POST /v1/templates/{name}/publish) |
| tenant template | shared store | the promoting node | a replacement must be signed by the node that signed the current version, or by a template signer; every node also remembers the signer it last saw per template name and requires a template signer to change it, so a delete followed by a new version does not reset the rule |
Operator promotes are rare operator actions, so the second signature costs a
step, not a process. The publish key is an ed25519 key the operator holds
(file, KMS or HSM); OIDC tokens authenticate but cannot sign content, so they
do not replace it. A control plane that drives tenant promotes may also
countersign them as a template signer.
Revocation. A reader rejects manifests and metas signed by a revoked node
after its since. Content signed before stays valid: a node's death does not
make what it signed suspect; its compromise does, from the moment listed. A
template signer re-signs what should outlive a revocation.
Keys and sweeps. Object keys gain the cell: {prefix}{cell}/.... The TTL
sweep and the departed-owner purge delete only objects this node signed, or
whose signer's lease expired.
A compromised node can delete objects (availability) but cannot plant an
operator template or make another claim boot a disk it wrote. Tenant templates
keep two narrow gaps, listed under Residual risk.
7. Clients
The SDK and any control plane:
- verify the node bundle and TLS binding of every node they call;
- follow a redirect or
owner_addronly to an address that a verified signed
state publishes.
8. Archive rows
putRow stops writing guest: false env values into the row (Env.Redacted
exists). The control plane re-injects them after a wake or adoption through
PATCH /v1/sandboxes/{id}/env.
What leaves the node
| Today | After |
|---|---|
api_token |
gone on clusters; operators use OIDC or client certificates |
mesh.cluster_key |
optional, not trusted |
| tenant tokens on the wire to every redirect target | not sent by an operator; SDK sends only to verified owners |
| claim tokens broadcast by the adopter | hash in the offer; the token goes only to the peer that accepts |
| claim env in archive rows | redacted |
Deployments without GCE
Nothing in the design depends on a cloud. A cell of bare-metal hosts, another
cloud, or a private datacenter needs:
| Piece | Provides it |
|---|---|
| node identity | a per-node X.509 certificate from the operator CA, installed at provisioning |
| operator identity | an OIDC issuer the operator already runs, or operator client certificates from the same or another CA |
| template publish key | an ed25519 key held by the operator |
| checkpoint store | any S3-compatible store (unchanged) |
| meta store | any PostgreSQL (unchanged) |
| time | NTP for certificate and token expiry checks (unchanged) |
The one recurring duty is certificate renewal, done by the operator's
provisioning. Because of continuity (§1), an expired certificate stops a node
only at its next restart, and the node logs a warning ahead of expiry.
Residual risk
Within one cell a compromised node can still:
- read every checkpoint and every row of the cell (confidentiality);
- delete objects and rows, or expire other nodes' leases in the table, which
makes owners drop claims (availability); - create a tenant template under a name that tenant has not used yet, which
the tenant clones only if it later asks for that exact name; on a node that
never saw the name's signer (a new node, a wiped cache), a delete followed
by a new version also passes; - sign truthful-looking but inflated warm counts, templates and volumes, and
draw a disproportionate share of new claims; an SDK that talks to nodes
directly then sends that node its tenant token, which stays a cluster-wide
bearer (a tenant key credential that never travels as a bearer would close
this; not in this design); - on a
tunpool, send frames whose inner source is another node's claim: the
exit learns MACs (learning,arp_accept=1in
advanced network mode) and trusts any VTEP for any
claim address. Closing it needs a static FDB driven by signed node states
and, on an L2 underlay, underlay anti-spoofing; - use whatever its own claims' credentials allow until they are rotated.
It cannot act as another node, read or use another node's claim tokens,
redirect clients to an address it does not publish, plant an operator
template, make another claim boot a disk it wrote, or reach another cell
(advanced network mode aside, above). Per-node confidentiality of the store needs
per-claim data keys, which this design leaves for later.
On x509, the certificate key sits on the node's disk, so a compromised node
can be admitted again as itself until it is revoked. That is the same node, not
a new one; revocation (§1) ends it.
The egress root stays cluster-wide. A node's intermediate signs only traffic
that passes through that node's own proxy, so another node's intermediate gives
no path to a guest it does not host.
Scale
- Verification is local: one proof check per node start, one signature check
per state change. - Gossip grows by about 100 bytes per node.
- A cell is the gossip, store-prefix and database-role unit. Large fleets run
many cells; nothing crosses cells. - Adding or replacing a node requires no per-node secret, account, role or
config entry beyond the certificate its provisioning already installs.
Hot path
The claim path is unchanged apart from TLS on the control port, which
keep-alive amortizes. Signature checks run on gossip merges, ownership changes
and store reads of a manifest, never per request or per frame.
Compatibility
Everything is opt-in behind identity; a node without it behaves as today.
| Deployment or client | Without identity |
With identity |
|---|---|---|
single host, api_token |
unchanged | not a target; a single host has no peers to protect |
cluster, cluster_key + api_token |
unchanged | migrate per Rollout |
| SDK with a tenant token | unchanged | unchanged; needs an SDK that speaks TLS and verifies bundles, or a transition plaintext listener |
SDK or script with api_token |
unchanged | refused over the network; use OIDC, an operator certificate, or admin_socket on the host |
| control plane serving many tenants | unchanged | operator identity plus X-Sandbox-Tenant; tenant tokens still accepted |
| guests, silkd, egress, previews | unchanged | unchanged |
Mixed versions during a rollout:
- Gossip. A node accepts unsigned states until
identity.require_signed
is set; routing prefers verified states while any are present. - Peer calls. A node in transition accepts both the root bearer and a node
certificate on move rounds, blob pulls and probes. The adopter uses the
two-phase offer with peers whose state advertises the new protocol and the
current broadcast with the rest. - Control port.
identity.plain_listenkeeps a plaintext listener for
tenant and sandbox tokens while SDKs upgrade; the operator role is never
served on it. - Store. Objects written before the upgrade carry no MAC or signature and
sit under the old prefix. At start each owner adds the MAC chain and its
signature to the records of its own claims, since it holds their tokens, and
a template signer signs the operator templates. Readers accept legacy objects
only untilidentity.require_signed. An archived claim whose owner never
returns stays legacy and is adopted only before that switch, or after a
control plane that holds its token re-records it. - Archive rows. Rows written before the upgrade keep their env and wake as
today; new rows are redacted, so a control plane must re-inject env on wake
or adoption before redaction is enabled.
Breaking changes appear only when an operator sets require_signed or removes
plain_listen, and each is a config switch, not a release.
Rollout
- Independent of identity, ships now: §8 env redaction, the two-phase
adoption offer, and the §3 table (tenant tokens stay off exec, relay,
ports and files). - Token-bound records (§6): closes store integrity without any PKI.
- Node identity, signed gossip and TLS roles, with both modes accepted:
unsigned states andapi_tokenstill work, signed states are verified when
present. Switch control planes to operator identity with
X-Sandbox-Tenant, then turnidentityon node by node. - Sign existing operator templates; require signed states and objects
cell-wide; rotateapi_tokenandmesh.cluster_key, since every node held
them. - Later, or stays under Residual risk: exit hardening for
tunpools and
tenant key credentials.
Work breakdown
| Package | Size |
|---|---|
| identity: key, X.509 and GCE providers, bundle endpoint, admission, continuity, revocation, JWKS cache | M |
signed NodeState, verified-only routing |
M |
| TLS control port, role auth, endpoint table | M |
operator OIDC and X.509, X-Sandbox-Tenant |
S |
| move source check, two-phase adoption offer | S |
| token-bound record MAC chain, verified before boot | M |
| signed manifests, cell key prefix, scoped sweep | M |
| template tiers: staged operator templates, publish endpoint, same-signer replacement | M |
| archive row env redaction | S |
admin_socket, plain_listen, require_signed, mixed-version peer calls |
M |
| legacy store signing pass | M |
| SDK: bundle and redirect verification | M |
config validation and probe MAC off cluster_key (config.go:553, :563, :838; probe.go:150) |
S |
Tasks
- identity: key, X.509 and GCE providers, bundle endpoint, admission, continuity, revocation, JWKS cache
- signed
NodeState, verified-only routing - TLS control port, role auth, endpoint table
- operator OIDC and X.509,
X-Sandbox-Tenant - move source check, two-phase adoption offer
- token-bound record MAC chain, verified before boot
- config validation and probe MAC that no longer need
mesh.cluster_key - signed manifests, cell key prefix, scoped sweep
- template tiers: staged operator templates, publish endpoint, same-signer replacement
- archive row env redaction
admin_socket,plain_listen,require_signed, mixed-version peer calls- legacy store signing pass
- SDK: bundle and redirect verification
- later or residual:
tunexit static FDB from signed states; tenant key credentials - docs: security.md deployment assumption 3 and the token model follow the new identity
Activity
CMGS commentedon Nov 7, 2026
Review against main
ef58ef76. The "What a compromised node holds today" table matches the code:mergecompares only the epoch (sandboxd/mesh/mesh.go:285), the adopter sends the claim token to every peer it tries (sandboxd/store/peer/adopt.go:24) and the adopter stores it (sandboxd/pool/failover.go:80),checkTemplateOwnerruns on the writer (sandboxd/pool/template.go:295), andputRowwritesEnvunredacted (sandboxd/pool/failover.go:138). The egress paragraph also holds: the root key stays offline and nodes hold only intermediates.The design leaves six gaps. Gaps 1 and 2 conflict with the stated goal.
1. Ownership still rests on rows a compromised node can write (§5, §6)
§6 accepts a manifest when its signer is the owner of record, and the owner of record is the archive row in PostgreSQL, which every node of the cell can write. Node X can rewrite any archived claim's row to
owner = Xandcheckpoint = <a disk X signed>, and keeptoken_sha256. When the victim wakes the claim, an honest node adopts it with the victim's own token, the hash matches, the signer equals the owner of record, and the claim boots X's disk. That replaces another node's checkpoint, which the design says X cannot do.Proposal: bind each record to the claim token, not to the row. Every archive and checkpoint record carries
mac = HMAC(claim_token, record_digest), where the record names the claim id, the checkpoint digest, a sequence number and the previous record's digest. A reader verifies the MAC with the token it holds, from the client or from the handing-over owner, before it boots anything. PostgreSQL becomes an untrusted pointer. A node that never held the token can only break availability, so the rule needs no owner-of-record lookup and no liveness evidence, and it also covers adoption after the owner is gone.2. The tun exit trusts any node for any claim address
The exit's
vxlan0runs withlearningandarp_accept=1(docs/advanced-network.md, single-host exit). X can send frames whose inner source is another node's claim. The exit then learns that MAC behind X's VTEP and sends the claim's return traffic to X. On a bare-metal L2 underlay, X can also spoof the exit's VTEP address toward another node'stun.listenand inject inbound frames. Either way X reaches guests it does not host.Proposal: run the exit with
nolearningand static FDB entries that an exit-side agent programs from signed node states. Each node lists its claims' tun addresses and MACs in its signedNodeState, and the agent drops frames whose inner source a VTEP did not sign for. Bare-metal spoofing needs underlay anti-spoofing (switch port security) or an authenticated underlay between nodes and exits. If that is out of scope, the advanced network mode belongs under Residual risk.3. Signed state proves origin, not truth; tenant tokens remain cluster-wide bearer secrets
X signs its own
NodeStatetruthfully as far as signatures go, but it can inflate its warm counts, templates and volumes to draw new claims. An SDK that talks to nodes directly then hands X its tenant token, and that token is valid on every node. For example,POST /v1/checkpoints/{id}/claimlets the holder read every checkpoint of that tenant across the cell. §4 removes this only for control planes, and Residual risk does not list it.Proposal: give tenants a credential that never reaches a node as a bearer, for example a tenant ed25519 key whose public half sits in the tenant record, used through a client certificate or a request signature bound to the node id and a timestamp. In any case, state under Residual risk that a compromised node can attract a disproportionate share of new claims.
4. The §3 table widens the tenant token
The table grants
tenant: ownfor exec, relay, ports and files. Today those accept only the sandbox token or the operator token (sandboxd/server/exec.go:47,sandboxd/pool/operator.go:91). Granting them to the tenant role makes gap 3 worse. These rows should staynofortenant.5. The §5 liveness rule is absence-based
The rule says "accept a new owner when no verified fresh state exists", and absence of evidence is what an attacker controls. X can withhold newer states or replay older ones. X can also declare Y dead at the SWIM layer, and
forget()then deletes Y's state from the view (sandboxd/mesh/mesh.go:265) until Y refutes it. If the client asks X itself, the check fails open. With proposal 1 this rule is no longer needed for integrity. If it stays, the evidence should come from a direct, nonce-bound challenge to the previous owner (GET /v1/identity?nonce=returning a signedAtand the nonce), never from the claimant.6. Delete-then-create replaces a tenant template
The same-signer rule compares a new version against the version currently in the store. X can delete objects (accepted as availability), and after a delete no current version exists, so X's version is accepted as the first one. Either pin the signer per template name outside the store (for example, each node remembers the signer and requires a template signer to change it), or list this under Residual risk next to the "name not used yet" item.
Not in the work breakdown
/owner. The adopter then never holds the plaintext token, while claim auth compares plaintext (sandboxd/pool/operator.go:91), so every token check would have to move to hashes. That also gives X a new move vector: copy the hash from PostgreSQL into a move's claim record. A simpler fix is a two-phase offer. Offer with the hash, and send the token only to the one peer that accepts. Refusing peers learn nothing, auth stays as it is, and the accepting node can verify the proposal-1 MACs.checkpoint_peer_heal,move_concurrencyandarchive_failoverrequiremesh.cluster_key(sandboxd/config/config.go:553,:563,:838). The design makes that key optional.cluster_key(sandboxd/store/peer/probe.go:150), so it has to move to node identity.Suggested order
CMGS commentedon Nov 7, 2026
All points verified against
ef58ef76and folded into the description.HMAC(claim_token, record_digest)over claim id, manifest digest, sequence and previous digest, verified before boot. PostgreSQL is an untrusted pointer. The node signature stays for attribution, sweeps and revocation only.tunexit learns MACs and trusts any VTEP (advanced-network.md:237,:242)exec.go:47,operator.go:91)nofortenant.mesh.go:265)cluster_key-dependent config and probe MACconfig.go:553,:563,:838;probe.go:150)Rollout follows the suggested order: env redaction, two-phase offer and the table fix first; token-bound records second; identity, signed gossip and TLS roles third; exit hardening and tenant keys later or as residual risk.
CMGS commentedon Nov 7, 2026
Follow-up review of the revised description against
main@ef58ef76d5daf02dd0a409069609d7deabad2a41.The architecture is feasible, but the current specification does not yet contain a host compromise to one node. The findings below use this issue's threat model: an attacker already controls host root on one admitted node. The existing residual risks for shared-store confidentiality, availability, tenant bearer exposure, and
tunremain separate from these findings.The first five items should be treated as P1 blockers for the proposed isolation guarantee. Items 1, 2, 3, and 5 are reachable paths in the current source that the design must close; item 4 is a flaw in the proposed operator authentication protocol.
S3 manifest paths can write outside the receiving node's staging directory.
Store.Fetch → populate → materializeconsumes a manifest from the shared store.materializejoinsfile.Pathto its local root and callsMkdirAll,Create, andTruncatewithout rejecting paths that escape that root. A compromised node with the shared store credentials can supply a traversal path and make another node create, truncate, or overwrite files outside its download directory when that node fetches the record. A later boot failure does not undo those writes.Evidence: Fetch, manifest decoding, materialization. The template path also calls
Fetchbefore checking the returned template's visibility: resolveGolden.Required change: confine every materialized path to the staging root, reject invalid manifests, and bound file counts and sizes. Verify the authenticated record and manifest before materialization. “Verified before boot” is too late if verification follows
Fetch. Path confinement remains mandatory even for signed records: a malicious admitted node can sign malformed data for a claim whose token it legitimately holds.SDK owner discovery still broadcasts the claim token.
Go
Client.Lookupprobes the entry node and then scatters requests to peers. EachownerAtrequest sends the plaintext claim token. Automatic rebinding after a move invokes that same lookup. Python lookup likewise probes the entry and peers with the token.Evidence: Go lookup and ownerAt, automatic rebind, Python lookup.
The two-phase offer in §5 changes the server-side adopter, but does not cover these SDK paths. TLS and a signed address prove that the recipient is an admitted node; an admitted but compromised peer can still collect a token belonging to a claim on another node.
Required change: make owner discovery secret-free and require evidence of authority over the specific claim before sending its bearer. A node's signed assertion that it is the owner is insufficient. An existing owner can prove possession of a claim-specific secret through a nonce-bound exchange; a new adopter needs an explicit authorized transfer. The hash already readable from PostgreSQL cannot serve as the secret for that proof.
The cluster-wide
preview_secretremains a lateral credential.A compromised node holds the shared preview HMAC key. For a known claim ID on another node, it can forge a preview token containing
{id, port, owner, exp}. The target validates the shared HMAC, thenPreviewDialaccesses the guest port usingCred{Operator: true}, without a claim token.Evidence: preview signing and verification, preview request handling, PreviewDial. The deployment documentation explicitly defines
preview_secretas cluster-shared.Required change: replace the shared signing authority with a capability scoped to the claim, including verification after migration. Merely accepting signatures from any admitted node preserves the same authorization flaw. The compatibility statement that previews remain unchanged conflicts with the isolation goal.
An operator OIDC bearer with a common audience can be replayed across nodes.
Under §3, every node accepts the operator ID token with
aud = "sandboxd". Once the control plane sends it to a compromised node, that node can replay it to another node until expiry and exercise operator privileges, including retrieving claim tokens. The statement in §4 that a redirect to a compromised node “harvests nothing” therefore does not hold for this credential.Required change: use operator mTLS for the initial implementation, or define a sender-constrained token protocol or a strict recipient-specific audience scheme that prevents cross-node replay. A short expiry only limits the attack window. This follows the replay threat and mitigations described in RFC 9700 §4.10.
The shared tenant table is still a writable authentication and authorization authority.
The tenant store uses the same configured DSN as the other shared backends, and tenant authentication directly resolves
sandboxd_tenants.token_sha256. A compromised node with that database role can replace a tenant's hash with one for an attacker-controlled token. An honest node then resolves that token as the victim tenant. This permits tenant-authorized operations on the victim's claims, such as modifying claim env; it does not require the SDK to send the victim's original tenant token to the compromised node.Evidence: backend wiring, tenant store wiring, token resolution, tenant record writes, env authorization.
Required change: protect tenant identity, quota, and policy records with operator authorization that a node cannot forge. Options include externally signed records verified by every reader, or a separate write authority whose credentials are absent from sandbox nodes. If node database access becomes read-only for these tables, the administrative write path must change accordingly. Adding MACs to checkpoint records does not protect the tenant table.
The record and identity protocols also need the following decisions before implementation:
Checkpoint.Newsends the tenant/operator credential, not the source claim token. A checkpoint handle does not retain that token, and the source claim may already have been released. See checkpoint claim and handle construction. Define a checkpoint-specific capability or another explicit means to authorize and verify this operation. The archive/adoption token flow does not establish compatibility for ordinary checkpoint cloning.since/identityendpoint does not establish the provenance of every old ephemeral key. A new verifier also has no previous admission state for a running peer whose original proof has expired. Retain historical public keys and their proofs independently of the writer's lifetime, define historical verification separately from live admission, and refresh the proof for a running key when needed by new verifiers. Continuity for existing verifiers alone does not solve either case.instance_id,project_id, andzoneas the globally unique tuple to verify.Suggested implementation order:
This can retain gossip and support bare metal without adding a central service. Local identity verification is feasible; ownership authority, record freshness, and revocation still require explicit trust rules.
Validation scope: source and protocol review only. No code changes, runtime tests, or exploit reproduction were performed.
CMGS commentedon Nov 7, 2026
Decision: the cell is the blast radius
After both reviews, the design is narrowed to keep it simple. A host compromise on one node is bounded to its cell, ops can cut a node or a cell off without that node's help, and no new protocol or central component is added. Cell size is the operator's knob.
Kept
sandboxd_tenantshas no cell column, and the other tables share one role across cells, so a shared database would not bound a cell.deny_nodeslist drops a node from routing and peer calls; then rotate the cell's credentials.deploy.mdandsecurity.mddescribe these values as shared within a cell, plus a cutoff and rotation runbook.Dropped
Node attestation and ephemeral keys, signed gossip, record MAC chains, template tiers, revocation timestamps and OIDC operator tokens. The findings they addressed stay inside the cell and are accepted there; per-node roles, per-node store prefixes and mTLS remain options if a cell cannot be made small enough.
The description above is the earlier, wider design and is kept for reference; this comment is the current scope.
CMGS commentedon Nov 7, 2026
Re-review against the cell-boundary decision, with source references at
main@ef58ef76.The revised scope is reasonable and implementable. Treating the whole cell as potentially compromised removes the need to solve the earlier per-node identity, ownership, and record-authentication problems in this issue. The remaining work is to enforce the boundary between cells and make the cutoff and recovery procedures accurate.
Three requirements still need to be explicit:
Blocking an address does not, by itself, guarantee immediate cutoff.
GCE and AWS stateful firewalls can retain established connections after rules change. A change that blocks new connections may leave PostgreSQL sessions, HTTP keep-alive connections, relays, and transfers already in progress usable. See GCE firewall connection tracking and AWS security group connection tracking.
The runbook needs a containment mechanism enforced outside the compromised host, with explicit handling of established connections and database sessions. Define a measurable cutoff condition rather than assuming that a rule update has ended access.
The same condition must cover credentials copied off the host. If an object store has a public endpoint that still accepts the stolen credentials, blocking the original node's network access does not cut off the attacker. Restrict access at the service boundary and revoke or rotate credentials as required. A private route alone does not disable public endpoint access; S3 documents this distinction.
deny_nodesis useful for stopping placement and outbound peer calls. It is not an authorization-revocation mechanism. With signed node identity removed, a compromised member can change its self-declared node ID. Network containment must also prevent source-address spoofing where the underlay permits it.Separate database/schema and store locations must have separate permissions.
A schema name,
search_path, or S3 prefix selects a namespace; it does not restrict what a stolen credential can access. PostgreSQL explicitly notes that access across schemas depends on privileges: schema documentation.Make the following deployment requirements explicit:
These are requirements for the new deployment contract, not a request to restore the dropped record-signing design.
The
tunexit must also respect the cell boundary.If multiple cells share one VXLAN learning domain, a compromised node in cell A can claim cell B's inner address/MAC and attract B's return traffic. The current exit example enables VXLAN learning and
arp_accept=1. The earlier accepted network risk is confined to a cell only if the exit topology confines it there.The simplest deployment rule is a separate exit network domain, FDB, and neighbor table per cell, with trusted ingress controls binding node sources to the permitted overlay. Different VNIs alone are insufficient: host root can construct a packet with another VNI. Bare-metal deployments also need underlay anti-spoofing outside the compromised host. The existing documentation already requires that other hosts cannot spoof exit VTEP addresses.
The earlier findings now have these dispositions:
Rotation and recovery need an implementation-aware runbook. At the reviewed commit, changing
api_token,mesh.cluster_key, orpreview_secretrequires a restart; an ordinary config reload rejects those changes. See ReloadDiff and the reloadable field set. Document the restart sequence and resulting service interruption, or implement the rotation behavior explicitly.Because the accepted blast radius is the entire cell, rotating keys alone does not establish that its remaining hosts are trustworthy. Recovery must account for possible compromise of other nodes and tampering with that cell's templates and checkpoints. Do not automatically restore trust in those artifacts merely because the old node has been blocked.
Suggested acceptance checks for this narrower scope:
I recommend keeping the simplified direction and incorporating these requirements into the current decision and implementation checklist. Replace the issue's active goal and task list with the cell-scoped version, or move the older design into a clearly separate historical section, so the implementation cannot accidentally follow the superseded per-node requirements.
Validation scope: source and design review only. No code changes, runtime tests, or inspection of deployed IAM, database grants, or firewall policies were performed.
CMGS commentedon Nov 7, 2026
Review 3 is folded into the description, which now leads with the cell scope; the per-node design sits in a collapsed history section.
deny_nodesis dropped: node ids are self-declared, and once the address is dropped SWIM removes the node from routing anyway.tunexit: one exit per cell with its own netns, FDB, neighbor table and underlay allowlist.config/reload.go; the runbook restarts the whole cell at once. Rotation without downtime is listed as an undecided option.No further sandbox code is required for this scope; the remaining tasks are docs and the runbook.
CMGS commentedon Nov 7, 2026
Host-only claim env at rest, split into issues (reviewed against
a2a0f366): values persist only inclaims.jsonand the archive/replica rows; never in the checkpoint store, the guest, the journals or any API read.claims.jsonwithout host-only valuesControl-plane counterparts: loomhand/cocoonbox#223 (re-push on
env_missing, must ship before #493/#494), #224, #225, #226.CMGS commentedon Nov 7, 2026
Done for the cell-scoped plan:
b679e778version fix)host_env_memory_only(off by default, restart only): sandboxd: a missing state for host-only env, kept off disk behind host_env_memory_only #499, closing sandboxd: a missing state for host-only env entries #492 sandboxd: claims.json stores no host-only env values #493 sandboxd: archive and replica rows store no host-only env values #494 packaging: disable core dumps for sandboxd #495 decision: drop host-only env values when a claim hibernates #496 docs: prefer claim-supplied secrets over node-level secrets #498; decision: store claim tokens hashed in claims.json #497 not plannedRollout order for #499: release with the flag off, deploy the control-plane re-push (loomhand/cocoonbox#228), then turn the flag on per node with a restart. The superseded per-node design stays in the collapsed section for history only.