#!/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 # ownership: a FLEET SEAT caller may resolve ONLY its own identity, where # the CALLER is established by process ANCESTRY, not by the # current environment: every ancestor's /proc//environ is # frozen at exec, so a child can rewrite its own MOSAIC_AGENT_NAME # but can never make an ancestor disagree with what the launcher # gave it (P5-RM-006; the dual-variable override was measured by # rev-code-02 F1). An anonymous caller (no lineage, no consensus) # may resolve NOTHING on a fleet host — seat or service # (rev-code-02 F2). Non-fleet hosts keep the documented legacy # paths below. # perms : a slot whose mode lets group or other read it (anything but # ?00) is refused — a loose slot is provisioning drift, and # serving from it silently widens every seat's exposure on a # single-account host. # 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 # ── The shared refusal path ────────────────────────────────────────────────── # Every fail-closed exit funnels through refuse(): a durable escalation record # (deduped, JSON-escaped), a stderr diagnostic naming host/identity/reason, # caller-supplied guidance when the refusing site has specific advice, exit 1. # Defined here because the ownership gate below must be able to reach it. refuse() { local guidance="${1:-}" local seat ts spool spool_record spoolfile dedupe seat="${MOSAIC_AGENT_NAME:-unknown}" ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) # A record field is arbitrary operator-supplied text: an identity comes from git # config or the environment, and cwd is whatever directory git ran in. Either can # contain a quote or a backslash, which would make the line unparseable JSON -- # and a spool that silently stops parsing is worse than no spool, because the # operator only discovers it while reading the record that explains an outage. json_escape() { local s=$1 s=${s//\\/\\\\} s=${s//\"/\\\"} s=${s//$'\t'/\\t} s=${s//$'\r'/\\r} s=${s//$'\n'/\\n} printf '%s' "$s" } spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}" spool_record="" if mkdir -p "$spool" 2>/dev/null; then chmod 700 "$spool" 2>/dev/null spoolfile="$spool/$(date -u +%Y%m%d).jsonl" dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)" if [ ! -e "$dedupe" ]; then : > "$dedupe" 2>/dev/null printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \ "$(json_escape "$ts")" "$(json_escape "$reason")" \ "$(json_escape "${ident:-}")" "$(json_escape "$ident_src")" \ "$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \ "$(json_escape "$host")" "$(json_escape "$PWD")" \ >> "$spoolfile" 2>/dev/null chmod 600 "$spoolfile" 2>/dev/null fi # Name the record only if one is actually on disk. Printing the path # unconditionally sends the operator to a file that does not exist on exactly # the hosts where the spool could not be created. [ -s "$spoolfile" ] && spool_record="$spoolfile" find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null fi while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})} reason : ${reason} EOF if [ -n "$ident" ]; then while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <&2; done <&2 else echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2 fi exit 1 } # ── Bash environment injection guard (rev-code-02 R3, B1) ─────────────────── # Non-interactive bash sources $BASH_ENV at startup and imports exported # functions from BASH_FUNC_* environment entries; either can define a read() # or printf() that shadows the builtin the ancestry walker and diagnostics # rely on — measured live by the reviewer's fixture (BASH_ENV read() rewrote # every ancestry entry). A legitimate fleet seat environment carries neither # (verified: zero BASH_FUNC_* in seat envs), so their presence in a helper # request is an injection attempt: scrub the shadows first (so even the # refusal machinery cannot be subverted), then refuse fail-closed. # Imported functions are detected by ENUMERATION, not env-var names: bash # consumes BASH_FUNC_* variables while importing the functions, so the # environment no longer shows them (measured). At this point the script has # defined exactly one function of its own (refuse); anything else in the # function table arrived from the caller's environment. BASH_ENV is checked # directly (it remains visible after sourcing). _injected=0 _inj_names="" while IFS=' ' builtin read -r _decl _kind _fn; do [ -n "$_fn" ] || continue case "$_fn" in refuse) ;; *) _injected=1; _inj_names="$_inj_names $_fn";; esac done < <(declare -F) _inj_vars="${!BASH_FUNC_@}" if [ -n "$_inj_vars" ]; then _injected=1 for _iv in $_inj_vars; do case "$_iv" in BASH_FUNC_*%%) _ifn="${_iv#BASH_FUNC_}"; _ifn="${_ifn%%%}";; BASH_FUNC_*) _ifn="${_iv#BASH_FUNC_}";; *) _ifn="";; esac [ -n "$_ifn" ] && { unset -f "$_ifn" 2>/dev/null; _inj_names="$_inj_names $_ifn"; } done fi if [ "$_injected" = 1 ] || [ -n "${BASH_ENV:-}" ]; then while IFS=' ' builtin read -r _decl _kind _fn; do [ "$_fn" = refuse ] || unset -f "$_fn" 2>/dev/null done < <(declare -F) unset BASH_ENV 2>/dev/null reason="bash-environment-injection-refused" refuse "The helper's bash startup state was externally shaped: BASH_ENV is set and/or exported BASH_FUNC_* functions are present in the request environment. Non-interactive bash sources BASH_ENV and imports those functions BEFORE any script line runs, so builtins this helper's security decisions rely on could be shadowed. Nothing resolves from a shaped request environment. If this surprised a legitimate workflow, the caller environment must be cleaned (no BASH_ENV, no exported functions) before invoking git." fi host=""; username_in="" while IFS= builtin 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 # ── Clean-entrypoint assert (P0-SEC R4) ───────────────────────────────────── # This implementation only runs behind the python entrypoint # (git-credential-mosaic), which execve's it with an allowlist environment: # no BASH_ENV, no imported functions, nothing shapable at bash startup. A # direct invocation without the marker is a bypass attempt on that boundary # and refuses. Placed after refuse() and the host parse so the refusal path # exists when it fires (an earlier placement died on 'refuse: command not # found' — the failure mode is real, keep this after every definition it # calls). if [ "${_MOSAIC_HELPER_CLEAN:-}" != "1" ]; then reason="direct-entrypoint-refused" refuse "This implementation refuses to run outside the production entrypoint. git-credential-mosaic (the python wrapper in this directory) execve's it with a hand-built, unshapable environment; invoking the .impl directly bypasses that boundary. Credential requests go through git, which invokes the wrapper named in gitconfig." fi 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" # ── Caller-identity ownership (P5-RM-006) ────────────────────────────────────── # A credential request is honourable only when the CALLER owns the identity it # asks for. On a fleet host every seat shares one unix account, so the # launcher-established MOSAIC_AGENT_NAME is the only attribution signal the # helper has. Two measured paths made the old contract unsafe: # # - a seat exporting MOSAIC_GIT_IDENTITY= resolved that seat's # token through the normal precedence chain (T97 G2, jarvis V2 probe), and # - an anonymous caller (no seat name) inherited the host gitconfig's # username=jarvis line and resolved jarvis's slot (T94: five watcher units # flapping on exactly this class). # # Ownership rules, fail-closed on fleet hosts only; a host with no fleet keeps # the legacy contract unchanged: # 1. a SEAT caller may resolve only its own identity; # 2. an anonymous caller may not resolve any SEAT identity (service # identities remain available to non-seat automation such as CI). # [P5-RM-006r1 ancestry binding begin] # ── Caller identity from exec-frozen ancestry (rev-code-02 F1/F2) ─────────── # Walk /proc self->root collecting MOSAIC_AGENT_NAME from each ancestor's # frozen environ. Rules: # - any DISAGREEMENT (an ancestor value != the current value, or ancestors # disagreeing among themselves) is a rewrite -> spoof-refused, nothing # resolves. A child can inject variables downward but cannot alter an # ancestor's exec-frozen environ, so the launcher-established value always # participates in the comparison. # - consensus (all ancestors that carry the var agree with the current env, # or with each other when the current env is empty) -> caller = that value. # - no ancestor carries it -> the current claim is unlineaged: caller is # anonymous regardless of what the environment says. A name with no # lineage is a claim, not an identity. # The walk stops at PID 1, at a missing /proc entry, or INCLUSIVE at an # ancestor that carries MOSAIC_CREDENTIAL_LINEAGE_FENCE with an EMPTY agent # name — the test-suite lineage root. A fence beside a non-empty name is # IGNORED and the walk continues, so an attacker cannot fence off the true # ancestry by planting the marker next to a victim name. trusted_caller() { # PATH-HARDENED (rev-code-02 R1 F1): every /proc read below uses ONLY bash # builtins (read/case/parameter expansion). The first implementation piped # through PATH-resolved tr/sed/head/grep, and a caller that prepends hostile # utilities to PATH in the same invocation that overrides the identity # variables could forge the ancestry itself. Builtins cannot be shadowed. local pid ppid v entry line fence local -a vals=() pid=$$ while :; do v="" fence=0 if [ -r "/proc/$pid/environ" ]; then # Read inside a captured subshell whose stderr is closed: opening # /proc//environ can fail with EACCES on ancestors that are # readable-by-mode but not openable (session managers), and that open # failure prints from the SHELL, immune to loop-level 2>/dev/null # (measured). The subshell makes the skip silent; NUL separators are # converted to newlines for the parent's builtin parse. _env_text=$( { while IFS= builtin read -r -d '' _e; do builtin printf '%s\n' "$_e"; done < "/proc/$pid/environ"; } 2>/dev/null ) while IFS= builtin read -r entry; do [ -n "$entry" ] || continue case "$entry" in MOSAIC_AGENT_NAME=*) v="${entry#MOSAIC_AGENT_NAME=}";; MOSAIC_CREDENTIAL_LINEAGE_FENCE=*) fence=1;; esac done </dev/null || true)" case "$slot_mode" in [0-7][0-7][0-7]|[0-7][0-7][0-7][0-7]) ;; *) slot_mode="unverifiable";; esac if [ "${slot_mode:1:2}" != "00" ]; then reason="slot-permission-violation" refuse "Slot $idtok has mode ${slot_mode:-unknown}; expected owner-only (0600 or stricter). Tighten it: chmod 600 '$idtok'. This refusal is the seat permissions half of P5-RM-006: a loose slot on a shared-account host is every seat's exposure, so the helper declines to serve from it. Mode inspection uses command -p (trusted PATH) and fails closed on unverifiable output." fi fi echo "username=${ident}" echo "password=$(<"$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 ─────────────────────────────────────────────────────────────── # The escalation RECORD is durable and unconditional; any notification built on # top of it is best-effort (see refuse()). 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. if [ -z "$ident" ]; then reason="no-identity" else reason="no-token-for-identity" fi refuse "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= # 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."