ci/woodpecker/pr/ci Pipeline failed
Two changes to one rule: a credential is resolved from exactly one place,
and an identity that cannot be resolved is refused rather than substituted.
FAIL CLOSED. Both readers ended in an unconditional fall-through to the
shared Gitea account whenever an identity did not resolve. Every seat in a
fleet therefore pushed, opened PRs and filed reviews under one account, and
a record made that way cannot be traced to the agent that made it
afterwards. The fallback now applies only where there is no attribution to
lose: a host with no fleet. Where seats exist, an unresolvable request emits
nothing, exits nonzero, explains itself on stderr, and — in the git helper —
appends a record naming the identity, host, reason and cwd, and no token
value, to ${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}.
A host runs a fleet when <brain>/fleet/agents exists, which is the signal
packages/mosaic/src/fleet/brain-home.ts already uses to decide a brain is
active, resolved the same way (MOSAIC_BRAIN_HOME, else ~/.mosaic). This is
what keeps the change a no-op for an operator who has not provisioned
per-slot tokens: no fleet directory, shared account, unchanged. It is also
why there is no environment variable to restore the old behavior — one would
reintroduce the substitution being removed.
STORE SELECTION. Both readers hardcoded ~/.config/mosaic/secrets/gitea-tokens,
so a seat's own secrets/ slot was invisible to the framework: a seat could
hold a valid credential and still be served the shared account. The store is
now chosen by what the identity is. An identity with a directory under
<brain>/fleet/agents/ is a seat and is read only from
<brain>/fleet/agents/<id>/secrets/; any other identity is a service identity
and is read from the framework store. There is no precedence between them
and no fallback from one to the other, so a seat with an empty slot is
refused even when a same-named token sits in the framework store. Two copies
of one credential are drift rather than redundancy, and drift surfaces as
the stale copy returning 401, which reads as a revoked token and sends
whoever debugs it somewhere else.
detect-platform.sh is in scope alongside git-credential-mosaic because they
are the two readers of these tokens. Patching only the git helper would make
"one credential, one location" true for push and fetch and false for
pr-create.sh, issue-create.sh and pr-review.sh, which is the harder failure
to notice.
TESTS. The three assertions that pinned the shared-account fall-through are
now fail-closed assertions, and a refusal is checked four independent ways:
nonzero exit, empty stdout, a stderr diagnostic naming identity and host,
and no shared token value anywhere in the output. The exit code alone would
pass against a helper that emitted the credential and then failed. Added:
seat-slot resolution, the no-cross-store-fallback case with a control
proving the framework-store file it declines to read is readable, no-identity
on a fleet host, the fleet gate firing on the default ~/.mosaic and not only
on an injected MOSAIC_BRAIN_HOME, and a cross-host leak check. Both suites
were run against the pre-change code as a control and fail there on exactly
the shared-token emission.
shellcheck is not installed on the authoring host, so the rewritten helper
is unlinted locally and CI is the first lint of it.
192 lines
8.6 KiB
Bash
Executable File
192 lines
8.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.
|
|
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:-<unset>}" "$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 <<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.
|
|
|
|
record: ${spool}/$(date -u +%Y%m%d).jsonl
|
|
EOF
|
|
exit 1
|