ci/woodpecker/pr/ci Pipeline failed
Pipeline 2514 failed the sanitization gate on 'Jason mints the token into the seat slot'. The denylist is jarvis|jason|woltje|... and a shipped framework file must not carry operator identity. My mistake: I generalized the estate paths and seat names when promoting this guide and did not check the operator name. Now reads 'the estate operator', with the accompanying rule that an agent does not ask another agent to mint one either. Verified by running tools/quality/scripts/verify-sanitized.sh locally rather than guessing at the pattern: gate passes.
128 lines
5.7 KiB
Markdown
128 lines
5.7 KiB
Markdown
# Seat Identity & Credentials Guide
|
|
|
|
Every agent that touches a Mosaic-managed git host acts as a named seat with its own credential.
|
|
This guide is how that works on a host, and what an agent must never do with it.
|
|
|
|
The mechanism below is the framework's. The specific paths, seats and stores are per-host:
|
|
measure yours before trusting any of them.
|
|
|
|
## The rule
|
|
|
|
**One seat, one identity, one token file.** A seat never borrows another seat's credential, never
|
|
falls back to a shared owner account, and never carries a second copy of its own token. A second
|
|
copy is drift, and drift surfaces as the stale copy returning 401 — which reads as a revoked
|
|
token and sends whoever debugs it somewhere else entirely.
|
|
|
|
A credential refusal is correct behavior, not a bug to route around. If git refuses with a
|
|
fail-closed diagnostic, the fix is to provision or correct *your* identity. Escalate; do not
|
|
substitute.
|
|
|
|
## How a credential is resolved
|
|
|
|
Find the helper the way **git** does, not with `command -v`. Git runs whatever
|
|
`credential.helper` names, and on a Mosaic host that is an absolute path — so a PATH lookup
|
|
answers a different question and the two disagree the moment the PATH copy is removed. It was
|
|
removed here on 2026-08-18.
|
|
|
|
```bash
|
|
git config --get-all credential.helper # every helper, in the order git tries them
|
|
```
|
|
|
|
Git tries **each** configured helper in turn until one supplies a credential. A fail-closed
|
|
helper supplies nothing, so a second helper configured behind it silently becomes the one that
|
|
answers. When you care which binary serves a credential, read the whole list.
|
|
`~/.mosaic/fleet/bin/lib-credential-helper.sh` does this correctly and handles all three forms
|
|
git accepts (absolute path, `!command`, bare name resolved on PATH).
|
|
|
|
The helper resolves the identity in this order:
|
|
|
|
1. `$MOSAIC_GIT_IDENTITY`
|
|
2. `git config --get mosaic.gitIdentity`
|
|
3. the username git supplied on stdin
|
|
|
|
It maps the host to a store prefix — `git.mosaicstack.dev` to `gitea-mosaicstack`,
|
|
`git.uscllc.com` to `gitea-usc`. Any other host is declined quietly with rc=0, which is not an
|
|
error and raises no escalation.
|
|
|
|
Then it chooses **one** of two stores, and reads exactly one file:
|
|
|
|
```
|
|
brain_home = ${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}
|
|
|
|
seat — when $brain_home/fleet/agents/<identity>/ EXISTS
|
|
$brain_home/fleet/agents/<identity>/secrets/<prefix>-<identity>.token
|
|
service — otherwise
|
|
~/.config/mosaic/secrets/gitea-tokens/<prefix>-<identity>.token
|
|
```
|
|
|
|
**There is no precedence between the two and no fallback from one to the other.** The existence
|
|
of the seat directory decides it. A seat that has a directory and an empty slot fails closed; it
|
|
does not reach the service store. That is the intended behavior — the alternative is an agent
|
|
silently acting as somebody else.
|
|
|
|
If the file is unreadable the helper **fails closed**: it refuses, spools a record, and attempts
|
|
a fleet notification. It does not fall back to a shared account. That fallback is what made
|
|
`usc/uconnect#3084` unattributable, and it was removed deliberately.
|
|
|
|
Verify the helper you actually have:
|
|
|
|
```bash
|
|
h=$(git config --get credential.helper)
|
|
grep -c 'FAIL CLOSED' "$h" # expect >= 1
|
|
grep -c 'fleet/agents' "$h" # expect >= 1; 0 means it predates mosaicstack#1311
|
|
```
|
|
|
|
## Where a seat's token lives
|
|
|
|
The seat slot is the **only** copy:
|
|
|
|
```
|
|
~/.mosaic/fleet/agents/<seat>/secrets/<prefix>-<seat>.token real file, mode 600
|
|
```
|
|
|
|
The framework store at `~/.config/mosaic/secrets/gitea-tokens/` holds tokens for **service
|
|
identities only** — identities with no seat directory. A seat's token does not belong there.
|
|
|
|
Before mosaicstack#1311 the deployed helper knew only the service store, and seats were bridged
|
|
with a symlink from the store into the slot. **Those bridges are gone** (removed 2026-08-18 by
|
|
`~/.mosaic/fleet/bin/migrate-credentials-to-seat-slots.sh`) and must not be recreated. A symlink
|
|
is not how a system finds a credential; the helper resolving the right store is.
|
|
|
|
`.principal` and `.scopes` beside the token are grant records, not secrets. They are tracked. The
|
|
`.token` never is.
|
|
|
|
### Provisioning a new seat
|
|
|
|
1. Create `~/.mosaic/fleet/agents/<seat>/secrets/` mode 700.
|
|
2. Write `.principal` (the Gitea login) and `.scopes` (the granted scopes), mode 600.
|
|
3. The estate operator mints the token into the seat slot, mode 600. Agents do not mint their
|
|
own, and do not ask another agent to mint one for them.
|
|
4. Symlink the framework store entry to the seat slot.
|
|
5. Verify with an authenticated `GET /user` and confirm the returned login is the seat, **not the
|
|
minting account**. Record the date in `ENTITY.md`. Never record the value.
|
|
|
|
Until step 3, the seat is unminted and its git writes fail closed. That is the designed state and
|
|
is safe to launch in — the seat is told at launch so it does not discover it mid-task.
|
|
|
|
## Acting as yourself
|
|
|
|
Name the identity on every invocation:
|
|
|
|
```bash
|
|
MOSAIC_GIT_IDENTITY=<seat> git push
|
|
git -c user.name=<seat> -c user.email=<seat>@mosaicstack.dev commit -m "..."
|
|
```
|
|
|
|
**Never persist `git config mosaic.gitIdentity` inside a `~/src/stack` worktree.** Every worktree
|
|
of that clone shares one `.git/config`, so a persisted identity there silently rewrites the
|
|
identity of every other seat working in that clone. The per-invocation form has no exception.
|
|
|
|
## Handling
|
|
|
|
1. **Never print a token value.** Compare by SHA-256 digest, or write `<REDACTED>`.
|
|
2. **Never stage a `.token`, `secrets.json`, or `ENTITY.md`.** Stage explicit paths and **never
|
|
`git add -A`** — `secrets/*.principal` and `secrets/*.scopes` are covered by no ignore rule.
|
|
3. **Never place a token in an environment variable** in an interactive session. A `declare -x`
|
|
dump has leaked the whole environment to a terminal before.
|
|
4. **No real credential or operator data on a sandbox VM, ever.**
|