From a47f7aec10cafaa3917a906f530303dd791d87e3 Mon Sep 17 00:00:00 2001 From: code-be-01 Date: Sun, 30 Aug 2026 16:42:23 -0500 Subject: [PATCH] framework: port R4 credential-helper pair from brain 15f6979a (python entrypoint w/ alpine bash-path candidates + impl, byte-faithful; P0-SEC T125) (cherry picked from commit 13e7398c2a2a0b245314b57cc0f1d76951169b2e) --- .../framework/tools/git/git-credential-mosaic | 268 ++-------- .../tools/git/git-credential-mosaic.impl | 480 ++++++++++++++++++ 2 files changed, 536 insertions(+), 212 deletions(-) create mode 100755 packages/mosaic/framework/tools/git/git-credential-mosaic.impl diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 931a70df..37c2c9f9 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -1,220 +1,64 @@ -#!/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. +#!/usr/bin/python3 +# git-credential-mosaic — production entrypoint (P0-SEC R4, rev-code-02 B1). # -# Install (one-time, per clone or globally): -# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic" +# WHY THIS IS NOT BASH: three review rounds falsified every in-bash startup +# guard. A non-interactive bash sources $BASH_ENV and imports exported +# functions BEFORE the first script line, so read(), unset(), exit(), +# declare(), printf() — every callable — can be shadows that fake the +# ancestry, defeat the scrub, or forge diagnostics (rev-code-02 probes 1 and +# 2, artifacts fc49e9d9 lineage). No in-language dispatch survives that. # -# Per-agent identity (Gate-16 author != reviewer separation): -# git config mosaic.gitIdentity # per-worktree, persists on disk -# # or: export MOSAIC_GIT_IDENTITY= +# This entrypoint is unshapable at the bash level: python does not read +# BASH_ENV and imports no bash functions, and the interpreter is pinned by +# absolute shebang (no PATH resolution). It builds the child environment BY +# ALLOWLIST and execve's the bash implementation directly — the child bash +# starts with no BASH_ENV, no BASH_FUNC_*, no SHELLOPTS/BASHOPTS, and exactly +# the variables the credential protocol needs. stdin/stdout/stderr and argv +# pass through untouched. # -# ── 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. +# The implementation file (git-credential-mosaic.impl) refuses to run without +# the clean-mode marker, so it cannot be invoked directly as a shaped-entry +# bypass of this wrapper. -[ "$1" = "get" ] || exit 0 +import os +import sys -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 +IMPL = os.path.join(os.path.dirname(os.path.realpath(__file__)), "git-credential-mosaic.impl") +# Absolute-path candidates ONLY — never PATH resolution (an attacker-shaped +# PATH must not choose the interpreter). /usr/bin/bash is the fleet-host +# layout; /bin/bash is alpine and other FHS variants (found by the T125 +# gateway-image verification: the hardcoded /usr/bin/bash made every call +# exit 127 inside node:22-alpine). +BASH_CANDIDATES = ("/usr/bin/bash", "/bin/bash") +BASH = next((p for p in BASH_CANDIDATES if os.access(p, os.X_OK)), None) -# 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 +# Allowlist: everything else in the environment dies at this boundary. Adding +# a variable here is a security decision — it crosses into a shell that no +# longer has any startup shaping, but it also becomes the only context the +# implementation can see. +KEEP = ( + "HOME", + "PATH", + "LANG", + "MOSAIC_GIT_IDENTITY", + "MOSAIC_AGENT_NAME", + "MOSAIC_BRAIN_HOME", + "MOSAIC_CREDENTIAL_SPOOL", + "MOSAIC_CREDENTIAL_LINEAGE_FENCE", +) -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 +env = {"_MOSAIC_HELPER_CLEAN": "1"} +for name in KEEP: + value = os.environ.get(name) + if value is not None: + env[name] = value -# ── 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. -# -# 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 - -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. - -EOF - -if [ -n "$spool_record" ]; then - echo " record: ${spool_record}" >&2 -else - echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2 -fi -exit 1 +argv = [BASH, IMPL] + sys.argv[1:] +if BASH is None: + sys.stderr.write("git-credential-mosaic: no executable bash at " + " or ".join(BASH_CANDIDATES) + "\n") + sys.exit(127) +try: + os.execve(BASH, argv, env) +except OSError as exc: + sys.stderr.write(f"git-credential-mosaic: entrypoint exec failed: {exc}\n") + sys.exit(127) diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic.impl b/packages/mosaic/framework/tools/git/git-credential-mosaic.impl new file mode 100755 index 00000000..0ba1c7d5 --- /dev/null +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic.impl @@ -0,0 +1,480 @@ +#!/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." +