#!/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 # per-worktree, persists on disk # # or: export MOSAIC_GIT_IDENTITY= # # ── 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 — /fleet/agents// exists # credential at /fleet/agents//secrets/-.token # service — it does not # credential at ~/.config/mosaic/secrets/gitea-tokens/-.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:-}" "$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 <}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})} reason : ${reason} EOF if [ -n "$ident" ]; then cat >&2 <&2 < # process-scoped git config mosaic.gitIdentity # 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