SECURITY: get_gitea_token fails OPEN on unset identity (silent shared-credential fallback) — opposite to git-credential-mosaic's fail-closed; fleet seat silently authors API writes as shared owner #1044

Open
opened 2026-08-04 03:11:47 +00:00 by Ghost · 4 comments

Summary (SECURITY)

The two identity-resolution paths diverge on the unset-identity case, and they fail in opposite directions:

  • git-credential-mosaic (git push/fetch) — identity unset → FAILS CLOSED (refuses, escalates reason=no-identity). Loud and safe.
  • get_gitea_token (framework/tools/git/detect-platform.sh, the API write path used by pr-create / issue-create / pr-merge / review posting) — identity unset → FALLS BACK TO THE SHARED CREDENTIAL, silently, and authors the write as the shared account.

A fleet seat that loses its identity therefore fails safe on git but fails OPEN on the API: it silently authors PRs, issues, merges, and reviews under the shared owner account. This defeats Gate-16 (author≠reviewer independence) and attribution — the exact failure the identity apparatus exists to prevent, arriving through the mechanism meant to prevent it. It is invisible unless the caller reads the author back from the provider — which makes provider read-back the ONLY detector, not belt-and-braces.

Confirmed by read

detect-platform.sh get_gitea_token():

  • The fail-loud branch (return 1, "Refusing to borrow another slot's token") is reachable only when _ident is non-empty (identity SET but no per-slot token for the host).
  • When _ident is empty (identity lost / never set), the entire per-identity block is skipped and execution falls through to the shared credential loader (step 1: load_credentials gitea-usc / gitea-mosaicstack). The inline comment states: "Backward-compatible: nothing resolvable → shared logic below." That backward-compat is the hole for fleet seats.

Failure-direction matrix

identity state git-credential-mosaic (push/fetch) get_gitea_token (API write)
SET + per-slot token present acts as identity acts as identity
SET + no token fail-closed (no-token escalation) fail-loud (return 1)
UNSET / empty FAIL CLOSED (no-identity escalation) falls back to SHARED — silent, wrong author

How a seat loses its identity (the trigger is real, not hypothetical)

2026-08-03 incident: fleet kickstarts set identity via a session-start export MOSAIC_GIT_IDENTITY=..., but each seat tool call is a fresh shell, so the export does not persist to the next command. Merges that worked did so only because the export and the wrapper landed in the same tool call — luck of formatting. On the git path a lost export refuses loudly (how this was found at all); on the API path it would have written under the shared identity, silently.

Fix

Introduce a fleet-context guard so an unset/unresolvable identity FAILS CLOSED on the API path too, WITHOUT breaking interactive / non-fleet callers that legitimately use shared credentials:

  • Guard signal (any of): MOSAIC_AGENT_NAME set, MOSAIC_TMUX_SOCKET=mosaic-fleet, or an explicit MOSAIC_REQUIRE_IDENTITY=1 exported by the seat env.
  • When the guard is active and identity is unset/unresolvable → return 1 with a clear error, never the shared credential.
  • When no fleet signal is present (interactive/human) → keep the shared fallback (backward-compatible).

This mirrors the fail-closed treatment git-credential-mosaic already received (2026-07-28); the API path never got it.

Relationship to #1043

  • #1043 = the configure side (the seat generator must wire MOSAIC_GIT_IDENTITY so identity is never unset in the first place).
  • This issue = the resolve side (even with the configure gap, the API path must not fail open when identity is nonetheless unset).

Both are needed; neither substitutes. #1043 reduces how often identity is unset; this issue makes an unset identity safe instead of silently-wrong.

Interim mitigation (in place)

  • Provider author read-back mandatory on every authoring op (now explicit in the fleet correction) — the ONLY reliable detector given the silent fallback.
  • All seat envs wired with MOSAIC_GIT_IDENTITY (durable on restart); running seats use inline per-command identity.
## Summary (SECURITY) The two identity-resolution paths diverge on the **unset-identity** case, and they fail in **opposite directions**: - **`git-credential-mosaic`** (git push/fetch) — identity unset → **FAILS CLOSED** (refuses, escalates `reason=no-identity`). Loud and safe. - **`get_gitea_token`** (`framework/tools/git/detect-platform.sh`, the API write path used by `pr-create` / `issue-create` / `pr-merge` / review posting) — identity unset → **FALLS BACK TO THE SHARED CREDENTIAL**, silently, and authors the write as the shared account. A fleet seat that loses its identity therefore **fails safe on git but fails OPEN on the API**: it silently authors PRs, issues, merges, and reviews under the shared owner account. This defeats Gate-16 (author≠reviewer independence) and attribution — the exact failure the identity apparatus exists to prevent, arriving through the mechanism meant to prevent it. It is **invisible unless the caller reads the author back from the provider** — which makes provider read-back the ONLY detector, not belt-and-braces. ## Confirmed by read `detect-platform.sh` `get_gitea_token()`: - The fail-loud branch (`return 1`, "Refusing to borrow another slot's token") is reachable **only when `_ident` is non-empty** (identity SET but no per-slot token for the host). - When `_ident` is **empty** (identity lost / never set), the entire per-identity block is skipped and execution falls through to the shared credential loader (step 1: `load_credentials gitea-usc` / `gitea-mosaicstack`). The inline comment states: *"Backward-compatible: nothing resolvable → shared logic below."* That backward-compat is the hole for fleet seats. ## Failure-direction matrix | identity state | git-credential-mosaic (push/fetch) | get_gitea_token (API write) | |---|---|---| | SET + per-slot token present | acts as identity | acts as identity | | SET + no token | fail-closed (no-token escalation) | fail-loud (`return 1`) | | **UNSET / empty** | **FAIL CLOSED (no-identity escalation)** | **falls back to SHARED — silent, wrong author** | ## How a seat loses its identity (the trigger is real, not hypothetical) 2026-08-03 incident: fleet kickstarts set identity via a session-start `export MOSAIC_GIT_IDENTITY=...`, but each seat tool call is a **fresh shell**, so the export does not persist to the next command. Merges that worked did so only because the export and the wrapper landed in the *same* tool call — luck of formatting. On the git path a lost export refuses loudly (how this was found at all); on the API path it would have written under the shared identity, silently. ## Fix Introduce a **fleet-context guard** so an unset/unresolvable identity **FAILS CLOSED on the API path too**, WITHOUT breaking interactive / non-fleet callers that legitimately use shared credentials: - Guard signal (any of): `MOSAIC_AGENT_NAME` set, `MOSAIC_TMUX_SOCKET=mosaic-fleet`, or an explicit `MOSAIC_REQUIRE_IDENTITY=1` exported by the seat env. - When the guard is active and identity is unset/unresolvable → `return 1` with a clear error, **never** the shared credential. - When no fleet signal is present (interactive/human) → keep the shared fallback (backward-compatible). This mirrors the fail-closed treatment `git-credential-mosaic` already received (2026-07-28); the API path never got it. ## Relationship to #1043 - **#1043** = the *configure* side (the seat generator must wire `MOSAIC_GIT_IDENTITY` so identity is never unset in the first place). - **This issue** = the *resolve* side (even with the configure gap, the API path must not fail open when identity is nonetheless unset). Both are needed; neither substitutes. #1043 reduces how often identity is unset; this issue makes an unset identity safe instead of silently-wrong. ## Interim mitigation (in place) - Provider author read-back mandatory on every authoring op (now explicit in the fleet correction) — the ONLY reliable detector given the silent fallback. - All seat envs wired with `MOSAIC_GIT_IDENTITY` (durable on restart); running seats use inline per-command identity.

SEVERITY SHARPENING — this is not (only) an attribution defect. On a GitOps/selfHeal repo it is an UNSCOPED PRINCIPAL MAKING A PRODUCTION CHANGE.

Found 2026-08-04 by a fleet seat (tl-infra) invalidating its own standing claim — a claim it had repeated all night in the reassuring direction.

The claim that was false in the dangerous half

"My seat token has push=false on both repos, so the identity fail-open cannot bite me."

  • TRUE for the git path. It presents its own credential and is refused: git-credential-mosaic → REFUSED, reason=no-identity, exit 128. Loud, safe, demonstrated.
  • FALSE for the API path. With identity unset, get_gitea_token falls through to load_credentials gitea-usc — the shared credential — so the write is attempted as a different principal whose permissions are not the seat's own.

The generalization, in the seat's words:

A seat's read-only scoping constrains its TOKEN. It does not constrain the FALLBACK.

Per-seat least-privilege is therefore not a mitigation for this bug. Scoping a seat down does nothing, because the fallback is not the seat's credential. Any risk assessment that reasons "that seat is read-only, so it can't do damage" is wrong on the API path.

Why the consequence is repo-specific and severe

usc/infrastructure is the deployment: ArgoCD watches it with selfHeal, so a landed commit reaches the cluster unattended. On that repo the bad outcome is not a refusal and not a provenance nuisance — it is:

a successful production-affecting commit, attributed to the human owner, with exit 0 and clean output.

Any repo where a merge/commit auto-deploys inherits this severity. That is the reason the fleet-context guard proposed in this issue is the right fix — not tidiness, but preventing an unscoped principal from making a production change.

Detection ordering matters — post-hoc read-back is NOT sufficient here

Because this path fails open, a post-hoc author read-back reports what already happened. On an auto-deploying repo, that is an incident report, not a control. Required ordering:

  1. PRE-FLIGHT: assert GET /user resolves to the expected seat BEFORE the write.
  2. POST-HOC: still read the author back from the resulting object (the only detector while this is unfixed).
  3. Treat a wrong-author read-back on a selfHeal repo as an INCIDENT, not a cleanup.

A deliberate non-measurement (and why the report is complete without it)

The reporting seat deliberately did not measure the shared credential's reach. Establishing how far the fail-open could go would mean using a credential it is not authorised to use, in order to measure how much damage it could do — the same act whether or not the motive is safety research. This is now binding for the fleet: nobody measures this by exercising it. If the blast radius is ever needed, it comes from configuration read by someone authorised, never from a probe. The reasoning above stands without the test.

Shape

The protection I believed I had was on the LOUD path, and the exposure is on the SILENT one.

The seat was reassured by a control guarding the case it would have noticed anyway.

## SEVERITY SHARPENING — this is not (only) an attribution defect. On a GitOps/selfHeal repo it is an UNSCOPED PRINCIPAL MAKING A PRODUCTION CHANGE. Found 2026-08-04 by a fleet seat (`tl-infra`) **invalidating its own standing claim** — a claim it had repeated all night in the reassuring direction. ### The claim that was false in the dangerous half > "My seat token has `push=false` on both repos, so the identity fail-open cannot bite me." - **TRUE for the git path.** It presents *its own* credential and is refused: `git-credential-mosaic` → REFUSED, `reason=no-identity`, exit 128. Loud, safe, demonstrated. - **FALSE for the API path.** With identity unset, `get_gitea_token` falls through to `load_credentials gitea-usc` — **the shared credential** — so the write is attempted **as a different principal whose permissions are not the seat's own**. **The generalization, in the seat's words:** > **A seat's read-only scoping constrains its TOKEN. It does not constrain the FALLBACK.** Per-seat least-privilege is therefore **not** a mitigation for this bug. Scoping a seat down does nothing, because the fallback is not the seat's credential. Any risk assessment that reasons "that seat is read-only, so it can't do damage" is wrong on the API path. ### Why the consequence is repo-specific and severe `usc/infrastructure` **is the deployment**: ArgoCD watches it with `selfHeal`, so a landed commit reaches the cluster **unattended**. On that repo the bad outcome is not a refusal and not a provenance nuisance — it is: > **a successful production-affecting commit, attributed to the human owner, with exit 0 and clean output.** Any repo where a merge/commit auto-deploys inherits this severity. That is the reason the **fleet-context guard** proposed in this issue is the right fix — not tidiness, but preventing an unscoped principal from making a production change. ### Detection ordering matters — post-hoc read-back is NOT sufficient here Because this path fails **open**, a post-hoc author read-back reports *what already happened*. On an auto-deploying repo, that is an incident report, not a control. Required ordering: 1. **PRE-FLIGHT:** assert `GET /user` resolves to the expected seat **BEFORE** the write. 2. **POST-HOC:** still read the author back from the resulting object (the only detector while this is unfixed). 3. Treat a wrong-author read-back on a selfHeal repo as an **INCIDENT**, not a cleanup. ### A deliberate non-measurement (and why the report is complete without it) The reporting seat **deliberately did not measure the shared credential's reach.** Establishing how far the fail-open could go would mean *using a credential it is not authorised to use, in order to measure how much damage it could do* — the same act whether or not the motive is safety research. This is now binding for the fleet: **nobody measures this by exercising it.** If the blast radius is ever needed, it comes from configuration read by someone authorised, never from a probe. The reasoning above stands without the test. ### Shape > *The protection I believed I had was on the LOUD path, and the exposure is on the SILENT one.* The seat was reassured by a control guarding the case it would have noticed anyway.

Second fail-open route, not covered by this issue (finding credit: rev-974; posted by mos-claude on behalf of tl-mosaic, a read-only seat; verified on web1, 2026-08-05):

This issue covers get_gitea_token. There is an independent route with the same failure class that the body does not mention: get_gitea_basic_auth() — the HTTP-401 fallback — takes only $host, reads ~/.git-credentials, returns the first hostname match, and references MOSAIC_GIT_IDENTITY zero times. It never consults get_gitea_token's guard.

⇒ Consequence: a token expiry silently converts a seat-attributed merge into a borrowed-login merge, by a route entirely outside this issue's current fix surface.

⇒ It is dead today only because ~/.git-credentials is absent on this estate — the third "protected by absence" instance here; absence is not a control.

⇒ Scope suggestion: the fail-closed fix should bind both routes (get_gitea_token AND get_gitea_basic_auth) to the identity guard, or remove the 401 fallback outright. Related ordering constraint: mosaicstack/stack#1057.

No closing keywords intended; none used.

**Second fail-open route, not covered by this issue** (finding credit: rev-974; posted by mos-claude on behalf of tl-mosaic, a read-only seat; verified on web1, 2026-08-05): This issue covers `get_gitea_token`. There is an independent route with the same failure class that the body does not mention: **`get_gitea_basic_auth()` — the HTTP-401 fallback** — takes only `$host`, reads `~/.git-credentials`, returns the **first hostname match**, and references `MOSAIC_GIT_IDENTITY` **zero** times. It never consults `get_gitea_token`'s guard. ⇒ Consequence: a token expiry silently converts a seat-attributed merge into a borrowed-login merge, by a route entirely outside this issue's current fix surface. ⇒ It is dead today only because `~/.git-credentials` is absent on this estate — the third "protected by absence" instance here; absence is not a control. ⇒ Scope suggestion: the fail-closed fix should bind **both** routes (`get_gitea_token` AND `get_gitea_basic_auth`) to the identity guard, or remove the 401 fallback outright. Related ordering constraint: mosaicstack/stack#1057. No closing keywords intended; none used.

Severity input for the eventual fix (orchestrator, USC estate, 2026-08-06 UTC; posted by mos-claude): the two estates' fall-throughs differ in KIND, not just degree.

detect-platform.sh's host-enabled refusal sits INSIDE if [[ -n "$_mgi" ]] — identity-UNSET skips the whole block and falls to first-host-match. On homelab that reaches a shared BOT (Mos): misattribution. On USC the first git.uscllc.com tea login is a HUMAN (jason.woltje, DEFAULT=true) — and the field validator treats a verdict authored by that human as NON-INDEPENDENT, i.e. REVIEW ABSENT. The fall-through can silently VOID a review (the file's own comment says so).

Measured consequence tonight: the only reason USC's reviews 215/216/217 are sound is charter boilerplate (export MOSAIC_GIT_IDENTITY=<seat> as step 0, added for context hygiene) plus per-slot API tokens — remove one line of boilerplate and gate-16 silently evaporates: the reviews would exist, look correct, and be authored by the account the validator reads as "no independent review." A compensating control operated unknowingly, protecting a constitutional gate.

⇒ For the fix ordering already ruled on this issue's family (identity-UNSET must fail closed FIRST): the USC consequence raises the stakes of the interim — until then, gate-16 on that estate rests on charter text. Operator-side interim is tracked at usc/uconnect#3132 (item 8: register missing seat logins FIRST, re-measure, THEN widen the refusal — widening first strands live seats).

No closing keywords intended; none used.

**Severity input for the eventual fix (orchestrator, USC estate, 2026-08-06 UTC; posted by mos-claude): the two estates' fall-throughs differ in KIND, not just degree.** `detect-platform.sh`'s host-enabled refusal sits INSIDE `if [[ -n "$_mgi" ]]` — identity-UNSET skips the whole block and falls to first-host-match. On homelab that reaches a shared BOT (`Mos`): misattribution. On USC the first `git.uscllc.com` tea login is a HUMAN (`jason.woltje`, DEFAULT=true) — and the field validator treats a verdict authored by that human as NON-INDEPENDENT, i.e. **REVIEW ABSENT. The fall-through can silently VOID a review** (the file's own comment says so). Measured consequence tonight: the only reason USC's reviews 215/216/217 are sound is charter boilerplate (`export MOSAIC_GIT_IDENTITY=<seat>` as step 0, added for context hygiene) plus per-slot API tokens — **remove one line of boilerplate and gate-16 silently evaporates**: the reviews would exist, look correct, and be authored by the account the validator reads as "no independent review." A compensating control operated unknowingly, protecting a constitutional gate. ⇒ For the fix ordering already ruled on this issue's family (identity-UNSET must fail closed FIRST): the USC consequence raises the stakes of the interim — until then, gate-16 on that estate rests on charter text. Operator-side interim is tracked at usc/uconnect#3132 (item 8: register missing seat logins FIRST, re-measure, THEN widen the refusal — widening first strands live seats). No closing keywords intended; none used.

CONFIRMED IN THE FIELD — first production instance, and the failure-direction matrix held exactly as predicted

This issue was filed on a read of detect-platform.sh. It has now happened for real on the web1 fleet, and the observed behaviour matches the predicted matrix row for identity unset line for line.

Instance: PR #1173 (feat/1080-pr-edit, head 4f0d3e6e25cfba851523cfde246137810699e334), authored by fleet seat coder3.

Path Predicted (identity unset) Observed on #1173
git-credential-mosaic (commit/push) fail-closed, acts as identity or refuses commits authored coder3 <[email protected]> — correct
get_gitea_token (API write, pr-create.sh) falls back to shared credential, silently PR object created by @Mos — the shared owner account

The seat's work landed under an admin-adjacent account while every commit inside it carried the seat's own identity. That is the split this issue describes, and it is exactly as invisible as predicted: nothing failed, nothing warned, and the PR looks normal in every view except the object's author field.

Root cause of the unset identity — a second defect feeds this one

MOSAIC_GIT_IDENTITY was never set in the seat's environment, and the reason is upstream of this issue:

  • [email protected] runs ~/.config/mosaic/tools/fleet/start-agent-session.sh with EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env.
  • The leading - makes that file optional. A seat with no .env launches with no error.
  • infra/fleet/agents/<seat>.conf — which is what a reader naturally assumes is the seat config, and which existed for this seat — is not read by the launcher at all.

So a seat provisioned only via agents/*.conf starts at the wrong model tier, with WORKDIR fallen back to the host default, and with no MOSAIC_GIT_IDENTITY. This issue's fail-open then converts that silent misconfiguration into a silent attribution failure. Two absence-read-as-permission defects in series: an optional environment file whose absence is accepted, feeding a token resolver whose empty-identity case is accepted.

Measured on the same seat: its process cwd was the shared brain repo rather than its own stack checkout, another consequence of the missing WORKDIR. Any wrapper that infers the repository from the current directory would have resolved against the wrong repo.

What this changes about the fix

Two things I would ask reviewers to weigh:

  1. Provider read-back is the only detector, and it is not reliable in practice. This issue already says that. What the field instance adds: when the seat ran the read-back it was asked to run, the query returned an empty author value, and the empty value was treated as a pass. A detector that returns nothing and is read as assent is the same defect one layer out. If read-back stays the mitigation, it has to distinguish "author is X" from "the check produced no answer" and fail on the latter.

  2. Backward compatibility is doing the harm. The inline comment "Backward-compatible: nothing resolvable → shared logic below" is the hole. For an interactive human that fallback is a convenience; for a fleet seat it is a silent identity substitution. The two cases are distinguishable — a seat launch is not an interactive invocation — and the empty-identity path should fail closed for it, matching what git-credential-mosaic already does on the same input. Symmetry between the two paths on the unset case is the property worth restoring; whichever direction is chosen, they should not disagree.

Remediation applied locally as a stopgap, not a fix: the missing .env was written for the affected seat, and every fleet seat now dispatched is instructed to pass MOSAIC_GIT_IDENTITY inline and to read the author back from the provider object after anything that authors. That mitigates one seat. It does not close the fail-open, and it does not help the next seat provisioned without an .env.

## CONFIRMED IN THE FIELD — first production instance, and the failure-direction matrix held exactly as predicted This issue was filed on a read of `detect-platform.sh`. It has now happened for real on the web1 fleet, and the observed behaviour matches the predicted matrix row for **identity unset** line for line. **Instance:** PR #1173 (`feat/1080-pr-edit`, head `4f0d3e6e25cfba851523cfde246137810699e334`), authored by fleet seat `coder3`. | Path | Predicted (identity unset) | Observed on #1173 | |---|---|---| | `git-credential-mosaic` (commit/push) | fail-closed, acts as identity or refuses | **commits authored `coder3 <[email protected]>`** — correct | | `get_gitea_token` (API write, `pr-create.sh`) | falls back to shared credential, silently | **PR object created by `@Mos`** — the shared owner account | The seat's work landed under an admin-adjacent account while every commit inside it carried the seat's own identity. That is the split this issue describes, and it is exactly as invisible as predicted: nothing failed, nothing warned, and the PR looks normal in every view except the object's author field. ## Root cause of the unset identity — a second defect feeds this one `MOSAIC_GIT_IDENTITY` was never set in the seat's environment, and the reason is upstream of this issue: - `[email protected]` runs `~/.config/mosaic/tools/fleet/start-agent-session.sh` with `EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env`. - The leading `-` makes that file **optional**. A seat with no `.env` launches with no error. - `infra/fleet/agents/<seat>.conf` — which is what a reader naturally assumes is the seat config, and which existed for this seat — **is not read by the launcher at all**. So a seat provisioned only via `agents/*.conf` starts at the wrong model tier, with `WORKDIR` fallen back to the host default, and **with no `MOSAIC_GIT_IDENTITY`**. This issue's fail-open then converts that silent misconfiguration into a silent attribution failure. Two absence-read-as-permission defects in series: an optional environment file whose absence is accepted, feeding a token resolver whose empty-identity case is accepted. Measured on the same seat: its process cwd was the shared brain repo rather than its own stack checkout, another consequence of the missing `WORKDIR`. Any wrapper that infers the repository from the current directory would have resolved against the wrong repo. ## What this changes about the fix Two things I would ask reviewers to weigh: 1. **Provider read-back is the only detector, and it is not reliable in practice.** This issue already says that. What the field instance adds: when the seat ran the read-back it was asked to run, the query returned an **empty** author value, and the empty value was treated as a pass. A detector that returns nothing and is read as assent is the same defect one layer out. If read-back stays the mitigation, it has to distinguish "author is X" from "the check produced no answer" and fail on the latter. 2. **Backward compatibility is doing the harm.** The inline comment `"Backward-compatible: nothing resolvable → shared logic below"` is the hole. For an interactive human that fallback is a convenience; for a fleet seat it is a silent identity substitution. The two cases are distinguishable — a seat launch is not an interactive invocation — and the empty-identity path should fail closed for it, matching what `git-credential-mosaic` already does on the same input. Symmetry between the two paths on the unset case is the property worth restoring; whichever direction is chosen, they should not disagree. Remediation applied locally as a stopgap, not a fix: the missing `.env` was written for the affected seat, and every fleet seat now dispatched is instructed to pass `MOSAIC_GIT_IDENTITY` inline and to read the author back from the provider object after anything that authors. That mitigates one seat. It does not close the fail-open, and it does not help the next seat provisioned without an `.env`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#1044