git credentials: fail closed, and read a seat's token from its own slot
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.
This commit is contained in:
fred
2026-08-18 16:19:43 -05:00
parent 245e0c427d
commit 3d2b712355
5 changed files with 555 additions and 117 deletions
@@ -23,6 +23,17 @@
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
# per-slot token scheme) -> Patch 2b does not apply; existing
# fall-through behavior is unchanged.
# 7. Credential store selection: an identity with a directory under
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
# slot; any other identity is a SERVICE and is read from the framework
# store. No precedence between them and NO fallback from one to the
# other — a seat with an empty slot is REFUSED even when a same-named
# token sits in the framework store.
# 8. Fail loud when NO identity resolves on a host that runs a fleet: where
# seats exist, an unattributable API call is refused rather than made
# under the shared account. On a host with no fleet the same call still
# returns the shared token (case 1), which is what keeps this change a
# no-op for non-fleet operators of the framework.
#
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
@@ -210,6 +221,94 @@ if [[ "$err" == *"no per-slot token at"* ]]; then
fi
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 7. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
# from its OWN secrets/ slot, not from the framework store. The brain home
# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it:
# MOSAIC_BRAIN_HOME, else ~/.mosaic.
# ---------------------------------------------------------------------------
BRAIN_DIR="$WORK_DIR/brain"
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR")
assert_eq "seat reads its own slot" "seatE-slot-token" "$out"
# ---------------------------------------------------------------------------
# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an
# EMPTY slot, while a framework-store token of the identical name is
# present and readable. It must be REFUSED rather than served that token:
# one credential, one location. A seat that silently reads a same-named
# service credential is the same substitution failure as the shared-account
# fallback, one store further down.
# ---------------------------------------------------------------------------
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token"
assert_failloud "seat with empty slot does NOT fall back to the framework store" \
"git.mosaicstack.dev" "seatF" \
"$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \
MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR"
# assert_failloud only screens stderr for the word "shared"; this store's token
# is not named that, so check for its value explicitly.
set +e
xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp")
set -e
if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2
fail=1
fi
# Control: that framework-store token IS readable, so the refusal above is the
# store rule firing and not an unreadable file. The same id, resolved against a
# brain home where it is not a seat, gets it.
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out"
# ---------------------------------------------------------------------------
# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast
# with case 1: the identical call on a host with no fleet still returns the
# shared token.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
set +e
noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp")
noid_rc=$?
set -e
noid_err=$(cat "$WORK_DIR/stderr-noid.tmp")
if [[ "$noid_rc" -eq 0 ]]; then
echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2
fail=1
fi
if [[ -n "$noid_out" ]]; then
echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2
fail=1
fi
if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then
echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2
echo "$noid_out$noid_err" >&2
fail=1
fi
if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then
echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2
echo "$noid_err" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
# the fleet gate must fire on the default path too, not only on an
# explicitly injected one. Case 1 ran before this directory existed; the
# same call now refuses, which also proves case 1 was measuring the
# no-fleet branch rather than passing for an unrelated reason.
# ---------------------------------------------------------------------------
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
set +e
dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp")
dflt_rc=$?
set -e
if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then
echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo "get_gitea_token identity resolution regression passed"
fi