Signer isolation
Design (non-normative, not shipped) · Closes embedding remaining gap (“The remaining hosted gap is signer isolation”). Wire contract unchanged: no new subject, no new stream, no new message type. This is a composition-root and OS-custody seam.
Design only. Nothing here is implemented. Review gate: this doc is approved before any code lands.
Citations. Every file / function below was read in this worktree at c5c462886. No seed, key,
or credential value is reproduced.
The account signer is the nkey seed that issues user JWTs for one space’s data account. Today the manager process loads that seed and also spawns agent children. This document moves the seed to a separate uid (and, where the operator opts in, a private mount namespace) so that an agent child, or code that later runs with the manager’s privileges after a compromise of the agent-facing surface, cannot read it. The proof is a failed read from an agent, with a positive control that the manager can still mint.
1. Where the signer lives today
Section titled “1. Where the signer lives today”A space is one NATS account. Every agent is a user in it. The module comment on
packages/core/src/provision.ts states the rule: the signing key stays with whoever holds
SpaceAuth.account.signingSeed. That field is the provisioner secret
(SpaceAccountAuth in provision.ts). The operator seed and the account seed are a higher
privilege (they re-issue account and operator JWTs). They are named below where rotation needs
them. They are not the signer this isolation is about.
1.1 The bytes
Section titled “1.1 The bytes”On a workstation the default store is workspaceSecretStore(root)
(packages/workspace/src/secret-store-fs.ts), which is new FsSecretStore(join(root, ".cotal")).
FsSecretStore.get reads the file as UTF-8. FsSecretStore.put creates the parent with
mkSecretDir (packages/core/src/secret-fs.ts, mode 0o700 at create) and writes with
writeSecretFileAtomic → writeSecretFile (mode 0o600 at create). Those modes contain other
uids. They do not contain a same-uid reader.
The signer-bearing keys (packages/workspace/src/auth-paths.ts):
| Store key | Function | Default path under .cotal/ |
|---|---|---|
BROKER_AUTH_KEY (auth/broker.json) |
broker operator + system-account JWTs | auth/broker.json |
spaceAccountKey(space) (auth/account.<hex>.json) |
this space’s data account, including account.signingSeed |
auth/account.<hex>.json |
SPACE_AUTH_KEY (auth/auth.json) |
legacy monolith / cotal mint --signer mount |
auth/auth.json |
getSpaceAuth(store, space) is the reader for every signer-bearing path: manager start, renewal,
CLI mint. It composes broker + account records, or accepts the legacy monolith when both split
records are absent. loadSpaceAuth(dir, space) is the filesystem twin, including the stripped
signer shape (stripSpaceAuth in provision.ts): operator blanked, account.signingSeed kept.
cotal mint --signer (implementations/cli/src/commands/mint.ts, mint) writes that stripped
bundle with writeSecretFile.
SecretStore itself says the quiet part (packages/core/src/secret-store.ts): get exports the
value, v1 accepts that signing material enters process memory (mintCreds), and a non-exportable
sign/mint seam would be a separate capability. Isolation is that seam.
1.2 Load, hold, use in the manager
Section titled “1.2 Load, hold, use in the manager”Manager (implementations/manager/src/manager.ts):
- Load.
constructorsetsthis.secretstoopts.secretStoreorworkspaceSecretStore(this.workspaceRoot)(ManagerOptions.secretStore).runStartassignsthis.auth = await getSpaceAuth(this.secrets, this.space)and keeps that composedSpaceAuthfor the process lifetime. - Hold.
this.auth.account.signingSeedis then in the manager’s address space. Injecting KMS/Vault throughsecretStorechanges where the bytes are stored at rest. It does not stopgetSpaceAuthfrom decrypting them intothis.auth.docs/embedding.mdalready names this: custody is resolved, isolation is not. - Use. Every mint in this process calls
mintCreds/provisionAgentwiththis.auth(or a closed-overauthfromrunStart).mintCreds(provision.ts) doesfromSeed(auth.account.signingSeed)andencodeUser(..., { signer }).provisionAgentis durables thenmintCreds(..., "agent").mintPublicUserJwtis the samefromSeedfor the closed remote-manager protocol.
Every production mintCreds / getSpaceAuth on this host is a signer client after isolation.
Smokes that mint into a fixture are not.
Manager (manager.ts) holds this.auth and passes it into mintCreds:
| Profile | Function |
|---|---|
supervisor |
runStart (standing cred, self-renewed from the same seed) |
provisioner |
withProvisioner; also spawn rollback / session-plane helpers |
issuer |
withIssuer |
lifecycle-executor |
withLifecycleExecutor |
endpoint-serve-executor |
withEndpointServeExecutor |
endpoint-serve |
registration / serve mint |
agent |
spawn (provisionAgent via withIssuer + withProvisioner); renewManagedStaticCred / driveManagedStaticCredRenewal |
purger |
opPurge |
deprovisioner |
static teardown |
retirement-requester |
retirement rail |
goal-writer |
goal index |
session-ledger / session-serving / session-caller |
console session plane |
RunHosting (implementations/manager/src/run-hosting.ts) is a second holder of the same
object. Manager.runStart constructs it with auth: this.auth (manager.ts).
RunHostingContext.auth is that SpaceAuth. Its mint sites:
| Profile | Function |
|---|---|
run-admitter |
withAdmitter |
run-driver |
drive (first mint) and renew (same nkey) |
run-mediator |
drive and renew |
run-operator |
withOperator (one-shot read / answer) |
Barrier helpers take opts.auth: SpaceAuth from the manager (or from the CLI below) and mint
in-process today:
| Profile | Function |
|---|---|
endpoint-evictor |
makeManagerEndpointEvictionEvidence (endpoint-evict.ts) |
endpoint-evictor |
makeManagerHolderLivenessProbe (holder-liveness.ts) |
Operator commands in the manager package load their own bundle with getSpaceAuth and mint.
They are not this.auth, but they are the same seed on this host:
| Profile | Function |
|---|---|
endpoint-serve-executor |
runReconcileGate (commands.ts) |
control-caller-privileged |
runDeregisterInstance (probe) |
endpoint-serve-executor |
runDeregisterInstance (delete) |
remintDaemonCreds (packages/workspace/src/renewal.ts) is the standing renewal owner: it
calls getSpaceAuth on the same store, then mintCreds for the delivery and membership-rw
profiles. The manager invokes it from runStart’s renewal path. cotal doctor auth --fix is the
offline caller.
CLI signer clients (each getSpaceAuth / getSoleSpaceAuth then mintCreds): mint
(implementations/cli/src/commands/mint.ts); up (provisioner, membership-rw, control-caller-admin);
spawn (operator, provisioner, deprovisioner); join (provisioner); status (observer, deployer);
agents (session-caller); down-manifest (teardown); backup / restore; delivery-proc
(delivery); spawn-manifest (channel-writer); attach scatter probe
(implementations/cli/src/lib/control.ts, control-caller-privileged). They run as a uid on the
signer allowlist after §6, never as cotal-agent.
connect.ts (packages/workspace/src/connect.ts) mints control-caller-privileged,
control-caller-admin, and deployer from target.auth. preflight.ts mints probe from
target.auth. Both are the CLI/workspace composition, not a child of the manager.
Other production processes on this host that mint from a loaded SpaceAuth today:
| Profile | Function |
|---|---|
issuer |
withIssuerSession (packages/core/src/issuer-session.ts); any caller that already holds SpaceAuth |
delivery |
runDelivery --dev-mint (implementations/delivery/src/delivery.ts); production delivery reads a pre-minted cred and does not load the signer |
run-mediator / run-admitter |
local cotal run (implementations/runtime/src/run-command.ts) from conn.auth |
channel-purger |
web() (implementations/web/src/web.ts); last use of conn.auth before the handler drops the seed |
session-serving / retirement-requester / endpoint-serve / remote-manager / goal-writer / session-ledger |
mintPublicUserJwt in openAuthAuthorityPlane (implementations/auth/src/service.ts): the credential helper at the issueManagerServiceAuthority arm, and the later session / retire / activate arms, each pass { space, account: { pub, signingSeed } } |
Auth-service process. It does not load the seed through getSpaceAuth. runAuthService
(implementations/auth/src/service.ts) calls loadServiceKeys (store.ts), which requires
dataAccount.signingSeed in the service-keys.json projection, then hands that pair to
openAuthAuthorityPlane and startAuthCallout. Production uses of those bytes in this process
are not only JWT minting:
| Use | File / function | What the bytes do |
|---|---|---|
JWT encodeUser |
startAuthCallout (callout.ts) fromSeed(opts.dataAccount.signingSeed) as userSigner |
connect-time user JWTs |
JWT encodeUser |
openAuthorityClient (authority-client.ts) fromSeed(opts.dataAccount.signingSeed) |
self-minted infra connections |
JWT mintCreds |
makeDeliveryAdminEvictor (barrier-evict.ts) builds a stripped SpaceAuth and mints supervisor |
per-call delivery-admin evictor |
JWT mintCreds |
makeDeliveryAdminPlaneOracle (plane-claim.ts) same stripped SpaceAuth |
per-call plane liveness oracle |
JWT openAuthorityClient |
makeDrainRepairers (drain-repair.ts) |
per-repair applier credential |
JWT mintPublicUserJwt |
openAuthAuthorityPlane (service.ts) issueManagerServiceAuthority |
remote-manager user JWTs |
| HMAC-SHA256 | remoteManagerCurrentRegistrationProof (retained-manager-validation.ts) createHmac("sha256", secret) |
registration proof over a domain-separated payload |
| HMAC-SHA256 | openAuthAuthorityPlane (service.ts) passes dataAccount.signingSeed as proofSecret into authorizeRemoteRetainedAgentValidation, authorizeRemoteManagerGoalIndexScan, and authorizeRemoteManagerAdmin, and as the secret of remoteManagerCurrentRegistrationProof when issuing nextRegistrationProof |
the same MAC, four call sites |
| Public-key derivation | userAuthTrustFingerprint (continuity.ts) publicFromSeed(keys.dataAccount.signingSeed) |
fromSeed then getPublicKey; the fingerprint does not sign |
deriveOwnerToken (derive.ts) also HMACs, but its secret is the owner-derivation secret from
loadOwnerSecret, not account.signingSeed.
Isolation that only wraps encodeUser leaves the HMAC call sites holding the seed. The auth
service is a sibling signer client on the same host. After §3 it dials the same socket for
sign-user and mac, and its store projection keeps pub / signingPub only.
1.3 What is not the account signer
Section titled “1.3 What is not the account signer”loadManagerInstanceIdentity / saveManagerInstanceIdentity (auth-paths.ts) persist the
manager’s serve nkey seed. That is the instance’s own user identity (SPEC 13.6), not the account
signing seed. Isolation of the account signer does not move it.
rotateSystemAccount / rotateSystemCreds (provision.ts, packages/workspace/src/system-rotation.ts)
need the operator seed. That material is broker-wide. It does not belong in the user-JWT signer.
1.4 The agent-facing surface is not a Unix control socket
Section titled “1.4 The agent-facing surface is not a Unix control socket”The brief’s “control socket” on the manager is HTTP. AttachEndpoint
(implementations/manager/src/attach-endpoint.ts) is created in Manager’s constructor
(attachHost default 127.0.0.1, consolePort default 0). AttachEndpoint.start binds
this.#http.listen(this.#port, this.#host). Data routes require the per-process console token
(#authorized). WebSocket upgrades are refused with 400. cotal attach reaches a manager over
the mesh session, not by dialing this face. There is no manager Unix socket to permission.
The Unix socket that does exist is seat custody, in packages/seat. It is the pattern this
design copies for the signer, not a path that currently carries the seed.
2. The threat
Section titled “2. The threat”The spawn path never changes uid, never unshares a mount namespace, and still forwards HOME.
File mode 0o600 is then the same uid on both sides of the “boundary”.
2.1 Uid
Section titled “2.1 Uid”LegacyPtyRuntime.spawn (implementations/manager/src/runtime/pty.ts) calls pty.spawn with
cwd and env: spec.env ?? {}. No uid / gid option.
Production Linux goes through CustodialPtyRuntime.spawn
(implementations/manager/src/runtime/custodial-pty.ts) → launchSeat
(packages/seat/src/launcher.ts). launchSeat spawns the custodian with detached: true and
a scrubbed env. runCustodian (packages/seat/src/custodian.ts) then pty.spawns the agent
with launch.env. Neither spawn drops uid. Custodian, agent child, and manager share the
operator uid that started cotal up. After isolation the manager is cotal-manager and cannot
setuid; §3.3 names the privileged launcher that performs the drop.
2.2 Mount namespace
Section titled “2.2 Mount namespace”No spawn site calls unshare or enters a private mount namespace. The agent’s view of
$ROOT/.cotal/auth is the manager’s view.
2.3 Environment inheritance
Section titled “2.3 Environment inheritance”launchEnv (extensions/connector-core/src/launch.ts) copies a fixed OS allow-list, including
HOME, USERPROFILE, and the XDG_* roots, plus spawnEnvAllow(config) extras
(packages/core/src/connector-config.ts, read by Manager at spawn as envAllow). The file
comment on launch.ts is honest: the allow-list does not close filesystem secret access. A child
with a shell reads ~/.cotal off disk.
Cotal connection material is not in the environment. materialEnv writes a 0o600 launch file
and puts only its path in COTAL_LAUNCH_MATERIAL (packages/core/src/launch-material.ts,
writeLaunchMaterial). That file holds the agent’s own creds path and control token, not
account.signingSeed. A same-uid process that wants the signer does not need it: it opens
auth/account.<hex>.json or auth/auth.json.
2.4 Sockets the child can reach
Section titled “2.4 Sockets the child can reach”Seat: runCustodian mkdirSyncs the socket directory at 0o700, listens on
socketPath(root, id) (packages/seat/src/record.ts, …/seat.sock), then chmodSync(..., 0o600).
Every request runs peerCredentials(sock) (packages/seat/src/peercred.ts, SO_PEERCRED) and
refuses a peer uid other than the custodian’s own. hello then checks the capability token.
Same uid is the intended manager-to-custodian path. An agent child that is that uid and that
learns the token (the launch JSON is the token’s home) is a same-uid peer. This socket does not
hold the account signer. It shows the only permissioned Unix pattern in the tree.
Manager attach: loopback HTTP plus a token printed to the manager’s own output. An agent that
can read that output and reach the bind host can hit roster / feed / POST /session/<name>.
That is session mint, still using this.auth in-process. It is not a Unix ACL.
2.5 What a compromise actually gets
Section titled “2.5 What a compromise actually gets”Two attackers, same missing boundary:
- Agent child (or a tool it shells out to). Same uid, same mounts,
HOMEset. It reads the account record and now mints any profilepermissionsForwill encode, includingadmin/provisioner/purger.docs/embedding.md(“Hazardous provisioning primitives”) already warns against co-locating signer access with an untrusted connector at the same OS uid. - Code running as the manager after the agent-facing surface is compromised.
this.authis already in memory;FsSecretStore.getstill works. Isolation cannot stop that process from asking a signer to mint while it is still the authorized client. Isolation can stop it from copying the seed for use after the manager is killed and the signing key is rotated.
3. The design
Section titled “3. The design”A dedicated signer process holds account.signingSeed. The manager, the auth service, and
cotal mint / remintDaemonCreds never observe the seed. They send already-built user-JWT
claims or a MAC payload over a permissioned Unix socket and receive a signed JWT or a hex
digest. If the socket is absent, or if a composed SpaceAuth or service-keys projection still
carries a non-empty signingSeed in the caller’s process, the caller throws. There is no
in-process fromSeed or createHmac(signingSeed) fallback.
Linux is the production path, matching @cotal-ai/seat. Other platforms refuse a static-auth
manager start with a named error rather than signing in-process.
3.1 Smallest interface
Section titled “3.1 Smallest interface”mintCreds, mintPublicUserJwt, encodeUser in openAuthorityClient / startAuthCallout,
and the stripped SpaceAuth mints in barrier-evict.ts / plane-claim.ts need the seed for
one call: encodeUser. remoteManagerCurrentRegistrationProof needs it for a different call:
createHmac("sha256", secret) over a domain-separated prefix plus a JSON payload
(retained-manager-validation.ts). Permission rows, lifetimes, issuance fences, and creds
wrapping stay in the caller. The signer does not implement permissionsFor, SPEC 13.9, or the
issuance ledger. A separate proof secret is not introduced: today’s four HMAC call sites already
bind the registration proof to this seed, and splitting it would leave a second retained secret
on the auth-service uid.
Two requests, one socket:
sign-user name: JWT name (the profile label mintCreds already passes to encodeUser) userPub: user nkey public id accountPub: data-account public id claims: the object mintCreds already passes as perms + principalTags iat, exp: the valid dates userValidDates already computed
mac domain: the exact prefix remoteManagerCurrentRegistrationProof already concatenates before the payload (today: cotal/manager-current-registration/v1 plus NUL) payload: the JSON string that function already HMAC-updates after the prefixsign-user replies with the signed user JWT string. The caller still runs fmtCreds /
issuance release. mac replies with the hex digest (the sha256: prefix is still applied by
remoteManagerCurrentRegistrationProof). userAuthTrustFingerprint needs only the public key
of this seed (publicFromSeed in continuity.ts); after isolation that value is
account.signingPub on the projection, so the fingerprint does not dial the socket.
The signer holds the seed for one space account. A host with several spaces runs one signer process per space (or one process with several private seeds, still no export).
3.2 What it refuses
Section titled “3.2 What it refuses”The signer never returns the seed, never logs it, and never writes it to a path the manager uid or the auth-service uid can read.
It refuses, loud:
- Peer uid not in the allowlist (
SO_PEERCRED, same helper aspeerCredentialsinpackages/seat/src/peercred.ts). The allowlist iscotal-managerand the auth-service uid (same group, or a dedicatedcotal-authuid added tocotal-manager). - Missing or mismatched capability token (same shape as
runCustodian’shello). accountPubother than the held account (sign-user).- A claims object that is not a NATS user-JWT body (no operator/account JWT encoding, no arbitrary byte sign).
macwhosedomainis not the one frozen prefixremoteManagerCurrentRegistrationProofuses. No generic HMAC, no empty domain, no caller-chosen hash.sign-userormacafter the process has been asked to stop holding the seed (rotation drain).- Any export / dump / rotate-seed op on the manager-facing socket. Rotation is §4.
A compromised manager can still mint for as long as it can open the socket. That is the authorized client. It cannot take the seed with it.
3.3 Uid split
Section titled “3.3 Uid split”Three uids, one group. The auth service, when present, is a fourth uid in that group:
| Uid | What it can open |
|---|---|
cotal-signer |
the seed file (0600), the socket (owner) |
cotal-manager |
the socket (group), the capability-token file (0640 signer:manager-group), the rest of the workspace |
cotal-auth (optional; otherwise the auth service runs as cotal-manager) |
the socket (group) and the token file; not the seed |
cotal-agent |
the agent’s own launch material and creds file, not the seed, not the token file, not the socket |
Suggested group: cotal-manager. The signer user owns the seed directory 0700. The socket
directory is 0750 cotal-signer:cotal-manager. The socket is 0660 after bind (the seat
pattern is chmodSync(path, 0o600) in runCustodian; here the group must be able to connect,
so 0660, then SO_PEERCRED allowlist). connect(2) needs the write bit on a Unix socket,
not the read bit, so 0660 is the smallest mode that admits the group.
cotal-agent is in none of these groups. It holds only its own primary group, and the
seat-launch helper refuses to spawn if it is a member of cotal-manager (§3.3). The seat
files a cotal-agent custodian writes still carry group cotal-manager, because the
directory they are created in carries the set-group-id bit, which is a property of the
directory rather than a group the writer holds.
The current spawn chain cannot perform that drop. launchSeat (packages/seat/src/launcher.ts)
spawns the custodian as the caller; runCustodian then pty.spawns the agent. Neither call
sets uid. A process already running as cotal-manager that calls setuid(cotal-agent) gets
EPERM. A linger systemctl --user unit cannot start a unit as another uid. Isolation is not
on until a privileged launcher exists; the manager throws at start if it is absent.
Named boundary: cotal-seat-launch, a system unit, not a user unit. It is the only process
that may change uid. The manager never receives CAP_SETUID.
- Unit:
cotal-seat-launch.service,User=root(or a dedicated user whoseCapabilityBoundingSet/AmbientCapabilitiesare onlyCAP_SETUIDandCAP_SETGID). - Socket:
/run/cotal/seat-launch.sock, directory0750root:cotal-manager, socket0660after bind. Same listen / chmod / truncation check asrunCustodian. - Auth:
peerCredentialsallowlist is thecotal-manageruid only, then a capability token (the seathellopattern). - Request: the existing custodian launch JSON plus target uid/gid, which the helper hard-codes
to
cotal-agent(it refuses any other target). - Action, before
setuid:-
Resolve the custody root from the launch JSON (
rootinlaunchSeat). Isolation changes the shipped default. Today’sdefaultCustodyRoot(implementations/manager/src/runtime/custodial-pty.ts) isjoin(homedir(), ".cotal", "seats"), so a manager started ascotal-managerlands at~cotal-manager/.cotal/seats. That path is not traversable bycotal-agentafter the drop.mkSecretDir(packages/core/src/secret-fs.ts) creates~/.cotalat0700(thenhardenPrivatereasserts0700on POSIX).FsSecretStore.put(packages/workspace/src/secret-store-fs.ts) callsmkSecretDir(dirname(p))for every secret under that tree, includingauth/. A NodemkdirSync(..., { recursive: true, mode: 0o700 })of the seats path (theCustodialPtyRuntimeconstructor andlaunchSeat) creates the same0700ancestor chain: measured,home,.cotal, andseatsall come out0700. Chowning only the custody root and the per-seat directory does not grant traverse of~cotal-manager/.cotal. Measured: openingseat.sockthrough an ancestor with execute stripped failsEACCESeven when the children are0777; the same open succeeds after that ancestor is0711. Widening~/.cotalto0711is refused: that directory holds every credential (auth/account.<hex>.json, launch material, daemon creds) andmkSecretDir/hardenPrivatewould put it back to0700on the next secret write. The helper therefore does not chmod.cotal. The shipped default after isolation is/var/lib/cotal/seats.CustodialPtyRuntimeon Linux static-auth uses that path unlessCOTAL_SEAT_ROOTnames another agent-traversable root.$HOME/.cotal/seatsis no longer a valid default. -
Walk every ancestor of the resolved root from
/to the seat directory. Required layout for the shipped default (install asserts this; the helper refuses a mismatch and never widens a mode it did not create):Path Mode Owner /kernel root /var0755root:root/var/lib0755root:root/var/lib/cotal0711root:root/var/lib/cotal/seats(custody root)2771root:cotal-manager/var/lib/cotal/seats/<id>(per-seat)2770cotal-agent:cotal-manager/var/lib/cotalis0711socotal-agentandcotal-managercan traverse toseats/without listing siblings (signer/lives under the same parent as0700 cotal-signer; execute-without-read is the same pattern as a private home).0711does not letcotal-agentopen a sibling it cannot name. The custody root is owned byroot, not bycotal-agent: the agent uid falls in the other class there and holds--x, so it can traverse into its own seat directory and cannot list the root or create a second entry in it. An operator override viaCOTAL_SEAT_ROOTmust match the same rule: every ancestor from/down to the parent of the custody root is traversable bycotal-agent(o+xor an ACL that uid holds), the custody root is2771root:cotal-manager, and no ancestor is a0700directory owned bycotal-manager(so not~/.cotal). If any ancestor is missing, not a directory, owned by the wrong uid, or has a mode that deniescotal-agentexecute, the helper throws, names the path, and does notchmodit. It never grants traverse on~cotal-manager,~cotal-manager/.cotal, or.cotal/auth. -
Assert that the
cotal-agentuser is not a member of thecotal-managergroup, by reading that group’s member list and the agent user’s primary group. If it is a member, throw and name both: section 3.2 givescotal-managerread on the signer capability-token file and connect on the signer socket, so an agent that held the group would reach the signer. This assertion is the reason the mechanism in (4) is the setgid bit and not group membership. -
mkdironly the custody root and the per-seat directory (join(root, id)) as root. Set the custody root to2771root:cotal-managerand the per-seat directory to2770cotal-agent:cotal-manager. The seat id is already minted before the launch:reserve()inCustodialPtyRuntime(implementations/manager/src/runtime/custodial-pty.ts) mints it and the manager records it durably, so the helper knows the directory name before any drop.The set-group-id bit on the per-seat directory is the whole mechanism. Linux gives a new inode the creating process’s effective gid unless the parent directory carries that bit, in which case the new inode takes the directory’s gid, and a new subdirectory takes the gid and the bit. Supplementary membership never selects an inode’s group. Measured on a real filesystem: inside a plain
0750directory whose group isG, a process with effective gid1000holdingGonly as a supplementary group created a0640file with gid1000, so the group could not read it; inside the same directory at2750, the same process created a0640file with gidG. Measured again from a process that did not holdGat all (effective gid and every supplementary group different fromG): inside the2750directory, its new0640file, its new0660bound socket, and its new subdirectory all came out with gidG, and the subdirectory carried the bit forward. That is why the custodian never needs the manager group.chowndoes not clear the bit on a directory (measured:2750stayed2750across achown), so the helper maychownandchmodin either order, and thechmodis the authoritative step.mkdircannot set the bit, because the mode argument is masked by the umask (measured:mkdirwith mode0o2750produced0750). The helper therefore alwayschmods aftermkdir.chmoditself is umask-independent (measured at umask000,022,027, and077).Today’s
CustodialPtyRuntimeconstructor (mkdirSync(this.root, { recursive: true, mode: 0o700 })) andlaunchSeat(mkdirSync(opts.root, … 0o700)thenmkdirSync(dirname(recPath), … 0o700)) create those paths as the caller. After isolation the manager iscotal-managerand cannot chown tocotal-agent. If those calls still run as the manager, the root stays0700cotal-managerand the dropped custodian cannot writeseat.sock. The helper creates only those two directories. It does notmkdir -pthrough/var/lib/cotalon a missing spine; install creates the0711parent, and a missing parent is the refusal in (2). -
Write the launch JSON into the per-seat directory as
launch.jsonmode0600,openit,unlinkit, and keep the descriptor. That is today’slaunchSeatsequence (writeFileSync(launchPath, payload, { mode: 0o600 }),openSync,unlinkSync), performed by the helper as root instead. The file is gone from the directory before any uid changes, and the open descriptor survives both theunlinkand thesetuid, so the token andspec.envreach the custodian on stdin with no agent-readable copy on disk at any moment. -
Then
fork; in the childsetgid(cotal-agent),initgroups("cotal-agent", cotal-agent),setuid(cotal-agent), andexecthe existing custodian entry with that descriptor as stdin. That is today’slaunchSeatminus the same-uidspawn. The PTY child then inheritscotal-agentwithout a second drop.
-
Process identities
Section titled “Process identities”Read every hop below against this table. Supplementary groups are the full set the
process holds after initgroups, not a subset.
| Process | Effective uid | Effective gid | Supplementary groups |
|---|---|---|---|
cotal-seat-launch helper |
root |
root |
none (setgroups([]) at start) |
| custodian after the drop | cotal-agent |
cotal-agent |
cotal-agent only |
| agent PTY child | cotal-agent |
cotal-agent |
cotal-agent only |
| manager | cotal-manager |
cotal-manager |
cotal-manager only |
| signer | cotal-signer |
cotal-signer |
cotal-signer only |
The custodian holds cotal-manager neither as its effective gid nor as a supplementary
group. Step (3) asserts that before the drop and the helper refuses otherwise. The
custodian and the agent child therefore fail the group class on the signer socket
(0660 cotal-signer:cotal-manager) and on the capability-token file (0640
cotal-signer:cotal-manager), whose other class is empty. The seat leaves still carry
group cotal-manager because the setgid bit put it there, not because the writer holds
it.
What the custodian writes after the drop
Section titled “What the custodian writes after the drop”Today’s runCustodian (packages/seat/src/custodian.ts) mkdirSyncs
dirname(launch.socket) (0o700), listens, chmodSync(launch.socket, 0o600), and
writeRecord (packages/seat/src/record.ts) writes record.json 0600 then
chmodSync(dirname(path), 0o700). Measured on a live launchSeat in this worktree:
directory 0700, seat.sock 0600, record.json 0600. Applying only a helper
chmod of the per-seat directory to 0750 left the two leaves 0600. Directory group
access does not add group permission to a 0600 leaf. Isolation therefore changes the
custodian, not a helper chmod of the directory. After the drop the custodian writes:
| Path | Mode | Owner | Who sets it |
|---|---|---|---|
/var/lib/cotal/seats/<id> |
2770 |
cotal-agent:cotal-manager |
helper, preserved by the custodian |
/var/lib/cotal/seats/<id>/record.json |
0640 |
cotal-agent:cotal-manager |
custodian chmod, group from the setgid bit |
/var/lib/cotal/seats/<id>/seat.sock |
0660 |
cotal-agent:cotal-manager |
custodian chmod after listen, group from the setgid bit |
/var/lib/cotal/seats/<id>/custodian.log |
0600 |
cotal-agent:cotal-manager |
custodian appendFileSync |
Four rules make that layout hold, and all four are changes to the shipped tree:
- The custodian sets
process.umask(0o027)before it binds.chmodis umask-independent, so the umask does not decide the final mode, but it does decide the mode the socket carries betweenlistenand thechmodon the next line. Measured at the inherited umask0002a freshly bound socket is0775, which is an other-class connect for the length of that window; measured at0027it is0750, which is not. Measured at0027the followingchmodSync(0o660)still lands0660. writeRecordstops callingchmodSync(dirname(path), 0o700). That call strips the setgid bit and the group bits from the directory the helper built (measured:2750became0700), which orphans every later inode and takes the manager’s traverse with it. It also stops writing the record0600: the mode becomes0640, set by an explicitchmodSync(path, 0o640)after the write, becausewriteFileSyncapplies itsmodeargument only when it creates the file (measured: a rewrite withmode: 0o600left an existing0640file at0640).writeRecordstops callingmkdirSync(dirname(path), { recursive: true, mode: 0o700 }), andrunCustodianstops callingmkdirSync(dirname(launch.socket), { recursive: true, mode: 0o700 }). On the directory the helper already made, both are no-ops (measured: a2770directory survivedmkdirSync(…, { recursive: true, mode: 0o700 })unchanged), so they are not the failure; the failure is the case where the directory is absent. A0700directory created there by the custodian would inherit groupcotal-managerfrom the custody root’s setgid bit and still deny the group every bit, which reads as a correct group and adopts asEACCES. Both calls become astatSyncassertion: the per-seat directory exists, is owned by this uid, has groupcotal-manager, and carries mode2770. Anything else throws by name before the PTY is spawned.runCustodianchmods the socket0660rather than0600afterlisten, keeping the truncation check ahead of it.
The peer check changes with them. Today’s handle in runCustodian refuses any peer
whose SO_PEERCRED uid is not process.getuid() (the “peer uid mismatch” destroy).
After the drop that uid is cotal-agent, so a distinct cotal-manager is refused even
on a 0660 socket. The launch JSON gains an adopterUid, which the helper fills in
from the SO_PEERCRED of the manager’s own connection to the launch socket and never
from a value the manager states. handle accepts that uid and no other. The agent uid
owns the socket and can therefore reach connect, and this check is what refuses it one
frame later, before hello is even read.
Readiness is the seat record, not a helper stdout line. launchSeat
(packages/seat/src/launcher.ts) polls readRecord(recPath) until custodianPid > 0
and childPid > 0. The dropped custodian still writes that record (writeRecord in
runCustodian). The JSON line { ready: true, childPid, custodianPid } is written to
custodian.log when logPath is set, else to stdout (custodian.ts). The helper
execs with the same launch JSON, so logPath is set and stdout is not the channel.
The helper polls record.json the same way launchSeat does. If that record never
appears, spawn throws. Today’s launchSeat is detached: true and, on an early
custodian exit, reads custodian.log (launcher.ts). The helper is the parent of the
fork rather than a detached spawn, so an early exit is a waitpid, and the helper is
root, so it can also read that 0600 agent-owned log and return the message to the
manager over the launch socket. The manager never opens the log itself.
The helper never reads the account signing seed and is not on the signer allowlist.
After exec it never chmods a path it created in this request. A COTAL_SEAT_ROOT
under ~/.cotal is a mismatch of (2) and is refused. The ancestor walk and both
mkdirs stay before setuid so the dropped custodian can create the leaves at all.
The adopt path, hop by hop
Section titled “The adopt path, hop by hop”The manager runs as the identity in the table above: effective uid cotal-manager,
effective gid cotal-manager, supplementary groups cotal-manager only. It is not the
owner of any inode below, so every access is decided by the group class. The
permission classes are checked in order and the first matching class decides, so owner
bits do not add to group bits and a group bit never rescues an owner-class denial.
Measured on this filesystem: a 0040 file could not be read by its own owner
(EACCES), and a 0060 socket could not be connected by its own owner (EACCES).
| Hop | Call | Inode | Mode | Owner | Class used |
|---|---|---|---|---|---|
| 1 | traverse | / |
kernel | root:root |
other x |
| 2 | traverse | /var |
0755 |
root:root |
other x |
| 3 | traverse | /var/lib |
0755 |
root:root |
other x |
| 4 | traverse | /var/lib/cotal |
0711 |
root:root |
other x, no r |
| 5 | traverse, readdir, unlink an entry |
/var/lib/cotal/seats |
2771 |
root:cotal-manager |
group rwx |
| 6 | traverse, readdir, unlink an entry |
/var/lib/cotal/seats/<id> |
2770 |
cotal-agent:cotal-manager |
group rwx |
| 7 | openat(O_RDONLY) |
…/<id>/record.json |
0640 |
cotal-agent:cotal-manager |
group r |
| 8 | connect |
…/<id>/seat.sock |
0660 |
cotal-agent:cotal-manager |
group w |
- Hops 1 to 4 are the traverse spine.
cotal-managerholds no bit but the other-classxon all four, which issearchpermission and nothing else. At hop 4 it cannotreaddir, so it cannot enumerate/var/lib/cotal/signer. - Hop 5 is the custody root. The manager is in its group, so it may
readdir(the proof in section 5 globs*/record.jsonfor a named seat) and may remove an entry, which is whatreapSeat(packages/seat/src/reap.ts) needs when itrmSyncs the seat directory.cotal-agentis other here and holds--x: it reaches its own seat directory and cannot list or create siblings. - Hop 6 is the per-seat directory. The manager needs
xto resolve the two leaves,rfor the same glob, andwbecausereapSeatunlinksrecord.jsonbefore removing the directory. Removing a name requires write on the containing directory, not on the file: measured,unlinkof a file inside a0570directory failedEACCESfor the directory’s owner and succeeded at0750. - Hop 7 is
readRecord/loadSeat(packages/seat/src/record.ts), reached fromCustodialPtyRuntime.adoptand from the helper’s readiness poll.readFileSyncneeds the grouprbit that the setgid bit put acotal-managergroup on. - Hop 8 is
SeatClient.connect(packages/seat/src/client.ts), called byadoptSeatSync(packages/seat/src/handle.ts) fromCustodialPtyRuntime.spawnand.adopt.connect(2)on a Unix socket requires the write bit on the socket inode, not the read bit. Measured by stripping bits on a live listener: mode0200connected, mode0400failedEACCES, mode0000failedEACCES.0660gives the group that write bit. - Inside the custodian,
handlecallspeerCredentials(sock)(packages/seat/src/peercred.ts), which readsSO_PEERCREDthrough the native helper and returns the peer’s pid, uid, and gid as the kernel recorded them atconnect. The custodian compares that uid withlaunch.adopterUidand destroys the socket on a mismatch. A uid cannot state its own credential here, so a compromised agent that reached hop 8 as the socket’s owner is refused at this line. - The manager then sends
hellowith thetokenit read at hop 7. The custodian compares it withlaunch.tokenand, on a match, adds the socket tocontrollers, disarms the unattended timer, and replies with the seat’s name, child pid, geometry, and status. What it hands over is that frame stream and nothing else: there is noSCM_RIGHTSanywhere inpackages/seat, so the PTY master descriptor stays in the custodian and the manager drives the seat by frames over the socket it already connected. reapSeatsignals only pids whose recorded start identity still matches, andkill(2)across a uid boundary isEPERMfor an unprivileged sender (measured against pid 1). A manager that must reap acotal-agentcustodian therefore goes back through thecotal-seat-launchhelper, which is the only process that may signal across that boundary. Reaping the record and the directory, hops 5 and 6, is the manager’s own work and needs no privilege.
A distinct second uid was not available on the workstation where this design was
written, so hops 1 to 8 name the class each access falls into rather than claiming a
cross-uid run. The class rule itself, the setgid inheritance, the connect write bit,
the owner-class-first ordering, the unlink requirement, the umask window, and the
chmod behaviour were each measured on a real filesystem as reported above. Section 5
is the operator procedure that exercises the whole path with the real uids present.
CustodialPtyRuntime.spawn becomes a client of this socket. It no longer mkdirSyncs the
root itself on Linux static-auth: that create is the helper’s privileged step. If the
socket is missing, or the peer is not the helper, spawn throws. No in-process pty.spawn
fallback on Linux static-auth.
Required changes, by file
Section titled “Required changes, by file”Stated as changes to the shipped tree, not as descriptions of it.
packages/seat/src/custodian.ts: setprocess.umask(0o027)at the top ofrunCustodian; replacemkdirSync(dirname(launch.socket), { recursive: true, mode: 0o700 })with astatSyncassertion that the per-seat directory is mode2770, owned by this uid, and in thecotal-managergroup; changechmodSync(launch.socket, 0o600)to0o660; addadopterUidtoCustodialLaunchand change the peer check inhandlefromcred.uid !== process.getuid()tocred.uid !== launch.adopterUid.packages/seat/src/record.ts: inwriteRecord, drop themkdirSyncof the parent, dropchmodSync(dirname(path), 0o700), write the record withmode: 0o640, andchmodSync(path, 0o640)after the write so a rewrite of an existing record still lands0640.packages/seat/src/launcher.ts: on Linux static-auth,launchSeatstopsmkdirSyncingopts.rootanddirname(recPath), stops writinglaunch.json, and stopsspawning the custodian. It sends the launch request to thecotal-seat-launchsocket and pollsreadRecord(recPath)as it does today. Thecustodian.logread on an early exit moves to the helper, which is root and can read that0600agent-owned file; the manager receives the message in the launch reply.
Seat records and seat.sock are written by the custodian as cotal-agent, under a
directory the helper created with the setgid bit, so they carry group cotal-manager
without the custodian ever holding that group. The agent still cannot open the signer
seed or the signer socket.
Optional mount namespace (operator hardening, not required for the proof in §5): the signer
unit uses a private mount for the seed directory. The manager’s namespace has only the socket
bind-mounted in. An agent with a stolen manager uid still cannot open the seed path because
it is not mounted there. The Unix socket plus SO_PEERCRED remain the mandatory gate.
3.4 How the manager reaches it
Section titled “3.4 How the manager reaches it”Copy the seat pattern, do not invent a new one:
- Directory created
0750, socket bound, mode set after listen (runCustodian). - Truncation check against the requested path (
runCustodian’slistening.address()guard). - Every frame:
peerCredentials(sock)then capability token (custodian.tshandle). - Length-prefixed JSON frames (
packages/seat/src/protocol.ts), two ops (sign-user,mac).
The manager is configured with the socket path (unit env or cotal.yaml / supervise flag). It
dials at runStart before getSpaceAuth is allowed to succeed with a signing seed. A
composed read used by the manager, remintDaemonCreds, and cotal mint must yield
account.signingSeed === "" (or omit it). mintCreds takes a signUser function from that
dial, not fromSeed. remoteManagerCurrentRegistrationProof takes a mac function from the
same dial, not createHmac over a local seed. runAuthService refuses a service-keys.json
whose dataAccount.signingSeed is non-empty; after isolation that projection is pub plus
signingPub.
getSpaceAuth staying able to return a seed is the residual that would make isolation optional
in process memory. The manager’s start therefore refuses a store value whose account record
still contains a non-empty signingSeed. The signer process is the only reader of the seed
file. The workstation store’s account record becomes the public half (pub, jwt,
signingPub). That split is the migration in §6.
3.5 Absent service
Section titled “3.5 Absent service”No fallback to in-process signing. Named refusals:
- Socket path missing or not a socket: throw, name the path, name the unit to start.
- Dial timeout / peer uid mismatch / token mismatch: throw, do not call
fromSeedorcreateHmac. getSpaceAuth/loadSpaceAuthfor a signer client returns a non-emptysigningSeed: throw, name the key, tell the operator to finish §6 (the seed is still readable by this uid).loadServiceKeysreturns a non-emptydataAccount.signingSeed: throw, same class of error. The auth service is a signer client; it does not keep a local HMAC secret.- Linux-only native
SO_PEERCREDhelper missing: throw, same class of error aspeerCredentialswhen the helper is absent. - Non-Linux: throw at manager start for any mesh that would have loaded
SpaceAuth. Open-mode meshes have no signer and are unchanged. - Seat-launch socket missing, peer not the helper, or helper not privileged: throw at start
and at spawn. No in-process
pty.spawnfallback on Linux static-auth. - Dropped custodian still writes
record.json0600orseat.sock0600, or still requires peer uid=== process.getuid(): throw at spawn.cotal-managercannot poll or adopt. No helper-only chmod fallback. - Per-seat directory missing the set-group-id bit, or not in the
cotal-managergroup, when the custodian asserts it after the drop: throw before the PTY is spawned. Without that bit every leaf takes the custodian’s own gid and the manager is locked out of a seat that otherwise looks live. cotal-agentis a member of thecotal-managergroup: the helper throws before it creates anything. That membership would give the agent uid the signer socket and the capability-token file.
SecretStore adapters that export the seed into the manager process do not satisfy this
design. A KMS that signs without export is a different implementation of the same
sign-user seam, not a second fallback.
remintDaemonCreds today returns per-file skips and never throws (renewal.ts). That
contract is not a fallback here: when isolation is on, a missing socket or a non-empty
signingSeed in the caller is a hard failure, and mintCreds itself refuses fromSeed
in a signer-client process. User-mode meshes still self-mint the supervisor cred from this
seed (runStart); they take the same socket.
4. Rotation after the split
Section titled “4. Rotation after the split”4.1 Revoking minted users
Section titled “4.1 Revoking minted users”Unchanged. SPEC 13.9 is the broker ceiling on what a user JWT may do; SPEC 13.1 is issuance, retirement, and eviction. Isolation does not add a subject. A stolen user cred is still retired on the ledger and kicked. A stolen seed is §4.2.
4.2 Rotating the data-account signing key
Section titled “4.2 Rotating the data-account signing key”rotateDataAccountSigningKey (provision.ts) requires auth.operator.seed and
auth.account.seed. A stripped signer cannot rotate (throw in that function). The user-JWT
signer must not gain those seeds.
Rotation is an operator procedure, not a manager RPC:
- Stop new
sign-user(signer drain) so no JWT is issued under a key about to be retired. - On an operator-only path (tty or a second socket bound
0600to the operator uid, not the manager group), run the existingrotateDataAccountSigningKeyin a short-lived process that already holds operator/account seeds (today:cotalas the operator). That process writes the new account JWT into the broker trust records the manager may read, and writes only the newsigningSeedinto the signer-only file. - Restart the signer (or a replace-seed command on the operator socket). The manager-facing socket still has no export.
- Reload the broker with the new account JWT (
signing_keysnow the new public key). The old signer is no longer trusted. - Remint standing creds through the socket: supervisor, delivery, membership-rw
(
remintDaemonCreds), live managed-static agents (renewManagedStaticCredcannot keep the old JWT; this is a new signing key, so it is re-issue, not the SPEC 13.15 same-generation renewal). Agents whose creds were not re-issued die at the broker.
Crash between 3 and 4: the signer holds a seed the broker does not yet list. Minted JWTs fail
preflight. remintDaemonCreds already refuses to overwrite last-good creds with an unproven
JWT. Crash between 4 and 5: live creds are broker-dead until remint; that is the same window
as today’s rotation.
4.3 System-account rotation
Section titled “4.3 System-account rotation”rotateSystemCreds stays FS-and-operator-seed as it is today (system-rotation.ts). It does
not talk to the user-JWT signer.
4.4 Manager compromise after isolation
Section titled “4.4 Manager compromise after isolation”Kill the manager. Rotate (§4.2). The attacker had mint-while-connected, not a copy of the seed. JWTs they minted remain valid until expiry or eviction; standing ones are re-issued under the new key or they die.
5. The proof
Section titled “5. The proof”Operator procedure on Linux. Substitute the workspace root, the space name, and the isolated uids from §6. Do not print file contents: a successful steal would be a JWT or a seed, and either one is a failed proof.
Do not print process argument vectors. Command lines on a shared host are a publication
surface (a runtime can pass a persona as an argv token). This procedure therefore never uses
ps -o cmd, ps -f, ps -ef, pgrep, pgrep -f, pgrep -u, or /proc/<pid>/cmdline.
Identify a process by uid, comm, unit metadata, a seat record.json, or a single named
environment key.
Positive control uses cotal mint (or a managed spawn) as the authorized client. The negative
arms run as the agent uid, which is the child’s uid after the spawn drop.
-
Confirm the three system units. Isolation is not a linger
--userlayout (that cannot change uid; §3.3). Prefer the unit, which already names the User:Terminal window systemctl show cotal-signer@<space> -p User -p ExecMainPIDsystemctl show cotal-manager@<space> -p User -p ExecMainPIDsystemctl show cotal-seat-launch -p User -p ExecMainPID# User=cotal-signer, User=cotal-manager, and User=root (or the dedicated setuid helper# user). Do not pass -p Environment (it dumps every variable).If the units are not installed yet, list by comm and uid only, then read one env key:
Terminal window ps -o user,pid,comm -C node# then, for each pid whose USER is cotal-signer or cotal-manager:tr '\0' '\n' < /proc/<pid>/environ | grep -E '^COTAL_NAME='# print only that key. Do not dump the rest of environ (tokens live there). -
Confirm the seed file is not readable by the manager uid or the agent uid. Assign the seed path once; later steps reuse it, they do not invent a new one:
Terminal window SPACE=<space>SEED=/var/lib/cotal/signer/$SPACE/signing.seedsudo -u cotal-manager test -r "$SEED"; echo $?sudo -u cotal-agent test -r "$SEED"; echo $?# both must print 1sudo -u cotal-signer test -r "$SEED"; echo $?# must print 0 -
Confirm the manager’s composed store view has no signing seed (the start refusal in §3.5). As
cotal-manager, a store read ofauth/account.<hex>.jsonmust not contain asigningSeedfield with length greater than zero. Do not dump the file; query existence only. Assign the workspace root and the account-record path before python runs:
ROOT=$(getent passwd cotal-manager | cut -d: -f6) ACCOUNT_RECORD="$ROOT/.cotal/auth/account.<hex>.json" sudo -u cotal-manager python3 -c \ 'import json,sysp=sys.argv[1]try: d=json.load(open(p))except (OSError, json.JSONDecodeError) as e: sys.stderr.write("UNREADABLE\n") raise SystemExit(2)seed=d.get("account",{}).get("signingSeed","")if seed: sys.stderr.write("LEAK\n") raise SystemExit(3)raise SystemExit(0)' \ "$ACCOUNT_RECORD"; echo $? # must print 0. 2 means this uid could not open the record. 3 means the record still # carries a signingSeed: stop, finish §6, rotate if that copy was ever readable by # another uid.-
Positive control: mint still works through the socket.
Terminal window umask 077ROOT=$(getent passwd cotal-manager | cut -d: -f6)sudo -u cotal-manager mkdir -p "$ROOT/cotal-proof"sudo -u cotal-manager cotal mint proof-agent --profile agent --out "$ROOT/cotal-proof/proof-agent.creds" >/dev/nullecho $?# must print 0. Judge only the exit status; do not copy stdout (it names the new# principal) into a log or channel. Do not write the creds file under /tmp. -
Negative control: the managed agent child cannot read the seed. Spawn one named agent whose seat
nameis unique on this host (example nameproof). Do not pick a pid by uid or recency. The manager records the seat id on the static slot (recordSlotCustodyinmanager.ts); the custodian writesrecord.json(writeRecordinpackages/seat/src/record.ts) withname,childPid, andchildStart. After isolation the shipped custody root is/var/lib/cotal/seats(§3.3), not$HOME/.cotal/seats(defaultCustodyRootincustodial-pty.tstoday).COTAL_SEAT_ROOTmay name another agent-traversable root. Pin the child by walking those records for that name and matchingchildStart, then enter its mount namespace:Terminal window SEAT_ROOT=/var/lib/cotal/seatsAGENT_NAME=proofAGENT_UID=$(id -u cotal-agent)pid=$(sudo -u cotal-manager python3 -c 'import json, pathlib, sysroot, name, expect_uid = sys.argv[1], sys.argv[2], sys.argv[3]hits = []for rec_path in pathlib.Path(root).glob("*/record.json"):rec = json.loads(rec_path.read_text())if rec.get("name") != name: continuepid = rec.get("childPid")start = rec.get("childStart")if not isinstance(pid, int) or pid <= 0: sys.exit("no childPid")if not start: sys.exit("no childStart")stat = pathlib.Path("/proc/%d/stat" % pid).read_text()token = stat[stat.rindex(") ")+2:].split(" ")[19]if token != start: sys.exit("pid reused")uid = pathlib.Path("/proc/%d/status" % pid).read_text()real = [ln for ln in uid.splitlines() if ln.startswith("Uid:")][0].split()[1]if real != expect_uid: sys.exit("not cotal-agent")hits.append(pid)if len(hits) != 1: sys.exit("need one live seat named %s, got %d" % (name, len(hits)))print(hits[0])' "$SEAT_ROOT" "$AGENT_NAME" "$AGENT_UID")SEAT_ROOT,AGENT_NAME, andAGENT_UIDare assigned on their own lines, then passed assys.argv. Capture only the printed pid. Do not print the record, the start token, or/proc/<pid>/environ. Then:Terminal window sudo nsenter -t "$pid" -m -- sudo -u cotal-agent test -r "$SEED"; echo $?# must print 1The account-record check is a second failed open of a path the parent already resolved. Pass that path as an argument to python, never as a shell assignment that another command later expands. Each arm writes a distinct sentinel on stderr and a distinct exit status, so a readable leaked seed cannot be mistaken for a failed open:
sudo nsenter -t "$pid" -m -- sudo -u cotal-agent python3 -c \ 'import json,sysp=sys.argv[1]try: d=json.load(open(p))except OSError: sys.stderr.write("DENY\n") raise SystemExit(1)except json.JSONDecodeError: sys.stderr.write("UNPARSEABLE\n") raise SystemExit(2)seed=d.get("account",{}).get("signingSeed","")if seed: sys.stderr.write("LEAK\n") raise SystemExit(3)sys.stderr.write("EMPTY\n")raise SystemExit(0)' \ "$ACCOUNT_RECORD"; echo $? # DENY / 1 is the pass: the agent uid cannot open the manager-owned record. # EMPTY / 0 means the record was readable but signingSeed was empty (migration # happened; isolation of this file is still incomplete). LEAK / 3 is a stolen # seed: stop, rotate (§4.2). UNPARSEABLE / 2 is a broken record, not a pass.-
Negative control: the agent child cannot dial the signer.
Terminal window SIGNER_SOCK=/var/run/cotal/signer/$SPACE.socksudo -u cotal-agent python3 -c \'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1])' \"$SIGNER_SOCK"; echo $?# must be non-zero (EACCES). If connect succeeded, the proof has failed even before peercred. -
The adopt path, as the modes on disk. Run this as the manager uid while the seat from step 5 is live. It checks the whole chain of §3.3 rather than one inode.
Terminal window stat -c '%n %a %U:%G' /var/lib/cotal /var/lib/cotal/seats# /var/lib/cotal 711 root:root# /var/lib/cotal/seats 2771 root:cotal-managerSEAT_DIR=$(sudo -u cotal-manager dirname "$(sudo -u cotal-manager \grep -rl "\"name\":\"$AGENT_NAME\"" /var/lib/cotal/seats/*/record.json)")stat -c '%n %a %U:%G' "$SEAT_DIR" "$SEAT_DIR/record.json" "$SEAT_DIR/seat.sock"# <dir> 2770 cotal-agent:cotal-manager# <dir>/record.json 640 cotal-agent:cotal-manager# <dir>/seat.sock 660 cotal-agent:cotal-managerA group of
cotal-agenton either leaf is the failure the set-group-id bit exists to prevent: the seat looks live and the manager cannot reach it. Then show the two accesses of hops 7 and 8, without printing either payload (record.jsoncarries the seat capability token):Terminal window sudo -u cotal-manager test -r "$SEAT_DIR/record.json"; echo $?# must print 0sudo -u cotal-manager python3 -c \'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.close()' \"$SEAT_DIR/seat.sock"; echo $?# must print 0: connect(2) needs the group write bit, which 0660 givesid -nG cotal-agent# must not list cotal-managerThe agent uid owns
seat.sockand can thereforeconnectto it. That is expected and is not a finding:handleinrunCustodianreadsSO_PEERCREDon the first frame and destroys any peer that is notlaunch.adopterUid(§3.3). What the agent uid must not reach is the signer, which is step 6. -
Repeat step 4 after steps 5 to 7 to show mint still works.
A proof that skips step 4, or that runs steps 5 and 6 as cotal-manager, proves nothing. A
proof that prints a seed or a JWT has leaked; destroy that output, rotate (§4.2), re-run.
6. Migration
Section titled “6. Migration”Existing self-hoster, one space, workstation store. Several spaces: one signer unit per space, same steps.
6.1 What the operator does
Section titled “6.1 What the operator does”- Create uids
cotal-signer,cotal-manager,cotal-agentand groupcotal-manager. The operator user that currently runscotal upis typically added tocotal-managerfor CLI mint. Give each of the three uids its own primary group and add none of them to another’s.cotal-agentmust not be incotal-manager: that group reads the signer capability-token file and connects to the signer socket. The seat-launch helper asserts this before it creates a directory (§3.3) and throws if it does not hold. - Install the signer unit. Create
/var/lib/cotalfirst as0711root:root(the traverse spine in §3.3; do not leave this parent0700). Seed directory/var/lib/cotal/signer/<space>/mode0700ownercotal-signer; socket/var/run/cotal/signer/<space>.sock; token file/etc/cotal/signer/<space>.tokenmode0640cotal-signer:cotal-manager. The signer leaf stays0700; only the parent is traversable. - Copy
account.signingSeedfrom the current account record into/var/lib/cotal/signer/<space>/signing.seedascotal-signermode0600. Do not leave a second copy in the journal, in a backup that the manager uid can read, or in the shell history. - Rewrite the account record (and any
auth/auth.jsonstripped bundle) sosigningSeedis empty. Keeppub/jwt/signingPub. Rewriteservice-keys.jsonthe same way:loadServiceKeystoday requires a non-emptydataAccount.signingSeed(store.ts); the implementation of this design must persistpub/signingPubwithout the seed, orrunAuthServicerefuses.putSpaceAuthtoday writesaccountwhole (auth-paths.ts); the implementation of this design must persist the public half without the seed. - Install
cotal-seat-launch.service(§3.3). Create/var/lib/cotal0711root:rootand/var/lib/cotal/seats2771root:cotal-manager(install must make both; the helper asserts them and never widens a mode it did not create). The set-group-id bit on the custody root is load-bearing, not cosmetic: it is what puts groupcotal-manageron each per-seat directory the helper creates inside it, and the same bit on that per-seat directory is what puts the group onrecord.jsonandseat.sock. Verify it withstat -c '%a %U:%G' /var/lib/cotal/seats, which must print2771 root:cotal-manager. Run the manager ascotal-manager, the signer ascotal-signer. Point the manager at the signer socket and the seat-launch socket. Do not setCOTAL_SEAT_ROOTto$HOME/.cotal/seats: that ancestor is0700cotal-manager(mkSecretDir) and the helper refuses it. Agents arecotal-agentonly because that helper creates the per-seat directory2770cotal-agent:cotal-manager, thensetuids beforeexecof the custodian. The dropped custodian, not the helper, then writesrecord.json0640andseat.sock0660, both in groupcotal-managerby inheritance from that bit, so the manager can poll and adopt while the custodian holds only its own group. The manager never drops uid itself and nevermkdirs a0700seats tree under~/.cotal. cotal mint,cotal doctor auth --fix, andremintDaemonCredsuse the same signer socket. They run as a uid on the signer allowlist.
6.2 What refuses if they do not
Section titled “6.2 What refuses if they do not”| If the operator skips | What throws |
|---|---|
| No signer unit / no socket | runStart, cotal mint, remintDaemonCreds: socket absent, named path, named unit |
| Socket up, seed still in the account record | runStart / getSpaceAuth for signer clients: non-empty signingSeed in this process |
Socket up, seed still in service-keys.json |
runAuthService / loadServiceKeys: non-empty dataAccount.signingSeed in this process |
Socket up, HMAC still computed in openAuthAuthorityPlane |
start refuses: createHmac over a local seed is the leftover the mac op exists to replace |
Socket up, seed file still 0600 as the old operator uid |
isolation is not on; start refuses if that uid is the manager uid |
No cotal-seat-launch socket / helper not privileged |
start refuses: cannot drop to cotal-agent |
Helper drops uid without creating the per-seat directory as cotal-agent |
spawn never becomes ready: runCustodian cannot write seat.sock under a 0700 manager-owned root (custodial-pty.ts constructor, launchSeat, writeRecord) |
| Custody root or per-seat directory created without the set-group-id bit | spawn refuses at the custodian’s directory assertion; without it the leaves take the custodian’s own gid and the manager fails the group class on both |
Helper chmods only the per-seat directory after ready, leaving shipped 0600 leaves |
start / spawn refuse: cotal-manager cannot readRecord or connect seat.sock (writeRecord 0600, runCustodian chmodSync(socket, 0o600)). The custodian must write 0640 and 0660 under a 2770 directory |
Dropped custodian still requires peer uid === process.getuid() |
spawn / adopt refuse: handle in runCustodian destroys a distinct cotal-manager with “peer uid mismatch”. The check must compare against launch.adopterUid |
cotal-agent added to the cotal-manager group to make the leaves readable |
helper refuses at spawn: that group reaches the signer socket and the capability-token file. The set-group-id bit is the supported mechanism |
Custody root still $HOME/.cotal/seats, or any ancestor 0700 cotal-manager |
helper refuses before setuid: cotal-agent cannot traverse mkSecretDir’s 0700 ~/.cotal; writeRecord never appears and launchSeat’s poll throws |
Agents still spawned as cotal-manager |
start refuses: spawn uid is not cotal-agent |
cotal mint --signer used as a mount of the seed into the manager |
refused; that command’s purpose (strip operator, keep signing seed) is the old co-location |
| Non-Linux host | static-auth manager start refuses |
cotal mint --signer as a way to hand a container the seed is replaced by handing it the
socket (or a KMS sign-user). A stripped bundle that still contains signingSeed is a failed
migration, not an alternate mode.
6.3 What does not change
Section titled “6.3 What does not change”SPEC 13.9, profiles, issuance fences, delivery, the attach HTTP face, seat custody of PTYs.
SecretStore still stores standing user creds and daemon creds. It no longer stores the
account signing seed on a path the manager can get.
7. Residuals
Section titled “7. Residuals”- Mint-while-compromised. A live manager that can dial the socket can still issue JWTs.
Isolation is seed-theft resistance plus agent-uid confinement, not an HSM policy engine
inside the signer. Narrowing which profiles the signer will encode is a later policy, not
required to close the gap
docs/embedding.mdnames. - Operator/account seeds. Still on the operator path for rotation and for
createBrokerAuth/createSpaceAccountAuth. A host that also wants those off the manager disk already has that as a separate custody problem. - Same-group Unix clients. Any uid in
cotal-managerthat can read the token file is an authorized mint client. Keep that group small. - Backups. A backup of
/var/lib/cotal/signeris the seed. Treat it like today’sauth/account.<hex>.json. A backup of.cotal/authafter migration must not still contain the seed; restore tests should run step 3 of §5. - Windows / macOS. No isolation in this design. Refuse rather than ship a same-uid manager that believes it is isolated.