git credentials: fail closed, and read a seat's token from its own slot
ci/woodpecker/pr/ci Pipeline failed

Two changes to one rule: a credential is resolved from exactly one place,
and an identity that cannot be resolved is refused rather than substituted.

FAIL CLOSED. Both readers ended in an unconditional fall-through to the
shared Gitea account whenever an identity did not resolve. Every seat in a
fleet therefore pushed, opened PRs and filed reviews under one account, and
a record made that way cannot be traced to the agent that made it
afterwards. The fallback now applies only where there is no attribution to
lose: a host with no fleet. Where seats exist, an unresolvable request emits
nothing, exits nonzero, explains itself on stderr, and — in the git helper —
appends a record naming the identity, host, reason and cwd, and no token
value, to ${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}.

A host runs a fleet when <brain>/fleet/agents exists, which is the signal
packages/mosaic/src/fleet/brain-home.ts already uses to decide a brain is
active, resolved the same way (MOSAIC_BRAIN_HOME, else ~/.mosaic). This is
what keeps the change a no-op for an operator who has not provisioned
per-slot tokens: no fleet directory, shared account, unchanged. It is also
why there is no environment variable to restore the old behavior — one would
reintroduce the substitution being removed.

STORE SELECTION. Both readers hardcoded ~/.config/mosaic/secrets/gitea-tokens,
so a seat's own secrets/ slot was invisible to the framework: a seat could
hold a valid credential and still be served the shared account. The store is
now chosen by what the identity is. An identity with a directory under
<brain>/fleet/agents/ is a seat and is read only from
<brain>/fleet/agents/<id>/secrets/; any other identity is a service identity
and is read from the framework store. There is no precedence between them
and no fallback from one to the other, so a seat with an empty slot is
refused even when a same-named token sits in the framework store. Two copies
of one credential are drift rather than redundancy, and drift surfaces as
the stale copy returning 401, which reads as a revoked token and sends
whoever debugs it somewhere else.

detect-platform.sh is in scope alongside git-credential-mosaic because they
are the two readers of these tokens. Patching only the git helper would make
"one credential, one location" true for push and fetch and false for
pr-create.sh, issue-create.sh and pr-review.sh, which is the harder failure
to notice.

TESTS. The three assertions that pinned the shared-account fall-through are
now fail-closed assertions, and a refusal is checked four independent ways:
nonzero exit, empty stdout, a stderr diagnostic naming identity and host,
and no shared token value anywhere in the output. The exit code alone would
pass against a helper that emitted the credential and then failed. Added:
seat-slot resolution, the no-cross-store-fallback case with a control
proving the framework-store file it declines to read is readable, no-identity
on a fleet host, the fleet gate firing on the default ~/.mosaic and not only
on an injected MOSAIC_BRAIN_HOME, and a cross-host leak check. Both suites
were run against the pre-change code as a control and fail there on exactly
the shared-token emission.

shellcheck is not installed on the authoring host, so the rewritten helper
is unlinted locally and CI is the first lint of it.
This commit is contained in:
fred
2026-08-18 16:19:43 -05:00
parent 245e0c427d
commit 3d2b712355
5 changed files with 555 additions and 117 deletions
@@ -507,37 +507,69 @@ get_gitea_token() {
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
# stored per-slot token for this host, act AS that agent so API tooling
# stored credential for this host, act AS that agent so API tooling
# (pr-create, issue-create, …) authors under the right identity — matching the
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
# git credential helper, which this block deliberately mirrors.
local _ident="${MOSAIC_GIT_IDENTITY:-}"
local _ident_src="MOSAIC_GIT_IDENTITY"
if [[ -z "$_ident" ]]; then
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "$_ident" ]]; then
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
# but no per-slot token exists for THAT identity. Refuse to fall through to the
# shared/default credential loader below — silently borrowing another slot's token
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
# caller aborts loudly rather than acting as the wrong identity.
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
return 1
# Recognized Gitea hosts are the ones carrying the per-identity token scheme.
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
if [[ -n "$_ident" && -n "$_idpfx" ]]; then
# Credential store selection: an identity is a SEAT or a SERVICE, and which
# one it is decides where its credential lives. No precedence between the
# two stores and no fallback from one to the other — a seat with an empty
# slot fails loud rather than reading a service credential of the same name.
# One credential, one location: two copies diverge, and the stale copy fails
# in a way that reads as a revoked token rather than as drift.
local _idtok _ident_kind
if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then
_ident_kind="seat"
_idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token"
else
_ident_kind="service identity"
_idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
fi
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
# but no credential exists for THAT identity. Refuse to fall through to the
# shared/default credential loader below — silently borrowing another identity's
# token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review
# attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop
# instead so the caller aborts loudly rather than acting as the wrong identity.
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2
echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2
return 1
fi
# FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist,
# every record must name the agent that made it, so an unattributable request is
# refused rather than handed the shared account. `fleet/agents` existing is the
# same signal brain-home.ts uses to decide a brain is active. A host with no fleet
# keeps the shared path below unchanged: there the shared account is the operator's
# own and there is no attribution to lose.
if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then
echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2
echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2
echo " Set MOSAIC_GIT_IDENTITY=<agent-id> or 'git config mosaic.gitIdentity <agent-id>'." >&2
return 1
fi
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)