ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
481 lines
23 KiB
Bash
Executable File
481 lines
23 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
|
|
# 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/<pid>/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:-<unset>}")" "$(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 <<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
|
|
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
|
|
expected : ${idtok}
|
|
EOF
|
|
fi
|
|
|
|
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
|
|
|
|
${guidance}
|
|
EOF
|
|
|
|
if [ -n "$spool_record" ]; then
|
|
echo " record: ${spool_record}" >&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 — <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"
|
|
|
|
# ── 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=<another-seat> 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/<pid>/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 <<EOF_ENV
|
|
$_env_text
|
|
EOF_ENV
|
|
fi
|
|
if [ "$pid" != "$$" ]; then
|
|
[ -n "$v" ] && vals+=("$v")
|
|
if [ "$fence" = 1 ] && [ -z "$v" ]; then
|
|
break
|
|
fi
|
|
fi
|
|
ppid=""
|
|
if [ -r "/proc/$pid/status" ]; then
|
|
while IFS= builtin read -r line; do
|
|
case "$line" in
|
|
PPid:*) ppid="${line#PPid:}"; ppid="${ppid//[[:space:]]/}";;
|
|
esac
|
|
done < "/proc/$pid/status"
|
|
fi
|
|
case "$ppid" in ''|0|1) break;; esac
|
|
pid=$ppid
|
|
done
|
|
local self="${MOSAIC_AGENT_NAME:-}" i consensus=""
|
|
if [ "${#vals[@]}" -gt 0 ]; then
|
|
consensus="${vals[0]}"
|
|
for i in "${vals[@]}"; do
|
|
if [ "$i" != "$consensus" ]; then
|
|
printf 'SPOOF'
|
|
return
|
|
fi
|
|
done
|
|
if [ -n "$self" ] && [ "$self" != "$consensus" ]; then
|
|
printf 'SPOOF'
|
|
return
|
|
fi
|
|
fi
|
|
printf '%s' "$consensus"
|
|
}
|
|
|
|
if [ -d "$brain_home/fleet/agents" ]; then
|
|
caller="$(trusted_caller)"
|
|
if [ "$caller" = "SPOOF" ]; then
|
|
reason="caller-identity-spoof-refused"
|
|
refuse "The MOSAIC_AGENT_NAME lineage disagrees within this process tree:
|
|
an ancestor established by exec carries a different value than the request.
|
|
A child process can rewrite its own environment but never an ancestor's
|
|
frozen environ, so disagreement is a rewrite, not a race. Nothing resolves
|
|
under a rewritten caller identity. If this surprised a legitimate workflow,
|
|
run git from the seat's own session, not from a rewritten environment."
|
|
fi
|
|
if [ -n "$caller" ] && [ -d "$brain_home/fleet/agents/$caller" ]; then
|
|
if [ -n "$ident" ] && [ "$ident" != "$caller" ]; then
|
|
reason="cross-seat-identity-refused"
|
|
refuse "A seat may resolve only its own credential slot. Caller seat is
|
|
'$caller' (ancestry-established); the request names '$ident'. Overriding
|
|
MOSAIC_GIT_IDENTITY (or a git config / URL username) to another seat's name is
|
|
exactly the path this refusal exists to close. If '$ident' auth is genuinely
|
|
required, that seat runs the operation itself or the orchestrator provisions
|
|
an explicit grant."
|
|
fi
|
|
else
|
|
# Anonymous caller on a fleet host (no lineage, or the lineage root is not
|
|
# a seat): NOTHING resolves — seat slots (T94 jarvis@ class) or legacy
|
|
# service credentials (rev-code-02 F2: credentialed services are seats;
|
|
# the legacy store is vestigial and not anonymously reachable).
|
|
if [ -n "$ident" ]; then
|
|
ident_kind="${ident_kind:-}"
|
|
[ -d "$brain_home/fleet/agents/$ident" ] && ident_kind="seat" || ident_kind="service identity"
|
|
reason="anonymous-credential-refused"
|
|
refuse "This caller has no seat lineage on a fleet host and asked for
|
|
'$ident' (a ${ident_kind}). Anonymous callers resolve nothing on fleet hosts:
|
|
seat credentials must never serve an unattributable caller, and credentialed
|
|
services are seats with their own sessions (the legacy service store is
|
|
vestigial). Run from the owning seat's session."
|
|
fi
|
|
fi
|
|
fi
|
|
# [P5-RM-006r1 ancestry binding end]
|
|
|
|
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
|
|
# P5-RM-006 seat permissions: a SEAT slot readable by group or other is
|
|
# provisioning drift, and on a single-account fleet host it widens every
|
|
# seat's exposure at once. Refuse rather than serve from a loose slot; the
|
|
# record names the path so the provisioning fix is one chmod away.
|
|
# Scoped to seat slots: the framework service store is operator-managed
|
|
# and outside this work unit's permission surface.
|
|
if [ "${ident_kind:-}" = "seat" ]; then
|
|
# command -p resolves stat from the POSIX default PATH (system
|
|
# directories), never the caller's PATH (rev-code-02 R3 B2: a shadowed
|
|
# stat reported a 0644 slot as 600 and the helper served it). Output is
|
|
# shape-validated: anything that is not 3-4 octal digits refuses.
|
|
slot_mode="$(command -p stat -c '%a' "$idtok" 2>/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=<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."
|
|
|