Files
stack/packages/mosaic/framework/tools/git/git-credential-mosaic
T
fred 3d2b712355
ci/woodpecker/pr/ci Pipeline failed
git credentials: fail closed, and read a seat's token from its own slot
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.
2026-08-18 16:19:43 -05:00

192 lines
8.6 KiB
Bash
Executable File

#!/bin/bash
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
# Mosaic credential store at runtime so remote URLs never embed secrets.
#
# Install (one-time, per clone or globally):
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
#
# Per-agent identity (Gate-16 author != reviewer separation):
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
#
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
# This helper used to end by emitting the shared account's token for any request
# it could not resolve to an identity. A seat with no identity, or with an
# identity whose token was never provisioned, therefore received the most
# privileged credential configured on the host — silently, and indistinguishably
# from correct operation. Every record it then created (commit, push, PR, review)
# was attributed to that shared account, so author != reviewer separation was
# unenforceable and the true actor was unrecoverable after the fact.
#
# Under-provisioning must fail loudly, not impersonate. A refused git operation
# is recoverable in one command; a merged pull request attributed to the wrong
# principal is not.
#
# ── CONTRACT ───────────────────────────────────────────────────────────────────
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
# username git supplies on stdin
# store : chosen by what the identity IS, with no precedence and no
# cross-store fallback (see "Credential store selection" below)
# hit : emit username + password, exit 0
# miss : emit NOTHING, spool a durable escalation record, explain on
# stderr, exit 1 — git surfaces the failure and nothing is attributed
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
# remotes handled by another helper)
#
# Backward compatibility is preserved for exactly one case: a host with no fleet
# and no identity requested still gets the shared account, because on such a host
# the shared account is the operator's own and there is no attribution to lose.
# A host that HAS a fleet has agents whose records must be distinguishable, so
# the shared fallback is refused there.
#
# A token is never written to stderr, to the escalation record, or to any log.
[ "$1" = "get" ] || exit 0
host=""; username_in=""
while IFS= read -r line; do
[ -z "$line" ] && break
case "$line" in
host=*) host=${line#host=};;
username=*) username_in=${line#username=};;
esac
done
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
# declined quietly — another helper owns it, and refusing would break it.
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) exit 0;;
esac
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
if [ -z "$ident" ]; then
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
ident_src="git config mosaic.gitIdentity"
fi
if [ -z "$ident" ]; then
ident="$username_in"
ident_src="the username git supplied"
fi
# ── Credential store selection ────────────────────────────────────────────────
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
# its credential lives. There is no precedence rule between the two stores and no
# fallback from one to the other: a seat whose slot is empty fails closed rather
# than reading a service credential that happens to share its name.
#
# seat — <brain>/fleet/agents/<ident>/ exists
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
# service — it does not
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
#
# One credential, one location. Two copies of one credential diverge, and the
# stale copy fails in a way that reads as a revoked token rather than as drift.
#
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
idtok=""; ident_kind=""
if [ -n "$ident" ]; then
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="$svc_store/${idpfx}-${ident}.token"
fi
if [ -r "$idtok" ]; then
echo "username=${ident}"
echo "password=$(cat "$idtok")"
exit 0
fi
fi
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
# is active. Where there are seats, records must be attributable, so an
# unresolvable request is refused instead of borrowing the shared account.
fleet_present=0
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh"
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
# the password field, not from the username string, so any non-empty
# placeholder works — deliberately NOT a real account name, since framework
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
echo "username=${GITEA_USER:-git}"
echo "password=$GITEA_TOKEN"
exit 0
fi
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
if [ -z "$ident" ]; then
reason="no-identity"
else
reason="no-token-for-identity"
fi
seat="${MOSAIC_AGENT_NAME:-unknown}"
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# The escalation RECORD is durable and unconditional; any notification built on
# top of it is best-effort. Record and alert are deduplicated separately — a cap
# on the alert alone lets the spool grow without bound exactly while the operator
# is being told nothing, so the louder the failure the quieter it gets.
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
if mkdir -p "$spool" 2>/dev/null; then
chmod 700 "$spool" 2>/dev/null
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
if [ ! -e "$dedupe" ]; then
: > "$dedupe" 2>/dev/null
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
"$ts" "$reason" "${ident:-<unset>}" "$ident_src" "${ident_kind:-none}" "$seat" "$host" "$PWD" \
>> "$spoolfile" 2>/dev/null
chmod 600 "$spoolfile" 2>/dev/null
fi
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
fi
cat >&2 <<EOF
git-credential-mosaic: REFUSED (fail-closed).
host : ${host}
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
reason : ${reason}
EOF
if [ -n "$ident" ]; then
cat >&2 <<EOF
expected : ${idtok}
EOF
fi
cat >&2 <<EOF
No per-identity credential resolved. This helper does NOT fall back to the shared
account: that fallback makes every record it creates attributable to one
principal, which is unrecoverable once a pull request has merged under it.
Fix (pick one):
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
Then provision that identity's credential at the path named above. An identity
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
seat and is read ONLY from its own secrets/ slot; any other identity is read from
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
If this identity legitimately needs git access and has none, ask the orchestrator
to provision one.
record: ${spool}/$(date -u +%Y%m%d).jsonl
EOF
exit 1