ci/woodpecker/pr/ci Pipeline was successful
Both defects found in review by rev-code-01 on #1311. F3 — the JSONL record interpolated every field with a bare %s. An identity comes from git config or the environment and a cwd is whatever directory git ran in, so either can contain a quote or a backslash. One such refusal turned the day's spool into unparseable JSONL, and the operator would only discover it while reading the record that explains an outage. Fields are now JSON-escaped. F2 — the diagnostic printed "record: <spool>/<date>.jsonl" unconditionally, but the record is only written inside the branch where mkdir -p succeeded. When the spool cannot be created the helper named a file that does not exist, on exactly the hosts where the escalation was lost. It now reports the real path or says NOT WRITTEN. Also: prettier on README.md, which was the format-step failure on pipeline 2508. It reflowed only the two tables this branch added. Tests: cases 14 and 15 cover both. Verified discriminating — against the previous helper with these same tests, case 14 fails with the unparseable record printed and case 15 fails on both assertions; against this one both pass. The first draft of case 14 used `ls "$spool"/*.jsonl | head -1`, which under `set -o pipefail` exits 2 on a missed glob and killed the suite with zero output — the same silent-nonzero failure rev-code-01 hit from a partial tools/ extraction and the reason this file exists. Replaced with a glob loop and a comment.
221 lines
9.6 KiB
Bash
Executable File
221 lines
9.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.
|
|
#
|
|
# 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
|
|
|
|
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.
|
|
|
|
EOF
|
|
|
|
if [ -n "$spool_record" ]; then
|
|
echo " record: ${spool_record}" >&2
|
|
else
|
|
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
|
|
fi
|
|
exit 1
|