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.
305 lines
15 KiB
Bash
Executable File
305 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
|
# resolution (Gate-16 author≠reviewer separation) and fail-closed refusal.
|
|
#
|
|
# Covers:
|
|
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
|
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
|
# 2. Correct token file path chosen per host
|
|
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
|
# 3. 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.
|
|
# 4. Fail-closed: an identity that resolves but has no credential is REFUSED —
|
|
# no output, nonzero exit, a stderr diagnostic, and a durable spool record.
|
|
# The shared account is never emitted in its place.
|
|
# 5. Fail-closed: no identity resolvable on a host that runs a fleet is also
|
|
# REFUSED, because records made there must name the agent that made them.
|
|
# 6. Backward compatibility, the one surviving fallback: no identity AND no
|
|
# fleet -> shared account, unchanged. On such a host the shared account is
|
|
# the operator's own and there is no attribution to lose.
|
|
# 7. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
|
# 8. Non-"get" verb -> exits 0 with no output.
|
|
#
|
|
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
|
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
|
FAKE_HOME="$WORK_DIR/home"
|
|
REPO_DIR="$WORK_DIR/repo"
|
|
BRAIN_DIR="$WORK_DIR/brain"
|
|
SPOOL_DIR="$WORK_DIR/spool"
|
|
SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
|
|
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
|
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
|
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
|
# _lib/credentials.sh, not the real one, to keep this test hermetic.
|
|
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
|
|
|
rm -rf "$WORK_DIR"
|
|
mkdir -p "$SVC_STORE" \
|
|
"$FAKE_HOME/.config/mosaic/tools/git" \
|
|
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
|
"$REPO_DIR" "$BRAIN_DIR"
|
|
|
|
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
|
chmod +x "$HELPER"
|
|
|
|
git -C "$REPO_DIR" init -q
|
|
git -C "$REPO_DIR" config user.email "[email protected]"
|
|
git -C "$REPO_DIR" config user.name "Test"
|
|
|
|
# Fake shared-account credential loader — stands in for
|
|
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
|
|
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
|
|
load_credentials() {
|
|
case "$1" in
|
|
gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
|
gitea-usc) GITEA_URL="https://git.uscllc.com"; GITEA_TOKEN="shared-usc-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
SH
|
|
|
|
fail=0
|
|
assert_eq() {
|
|
local desc="$1" expected="$2" actual="$3"
|
|
if [[ "$expected" != "$actual" ]]; then
|
|
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
|
fail=1
|
|
fi
|
|
}
|
|
|
|
# Feed "host=<h>\nusername=<u>\n\n" on stdin (mirrors git's credential protocol)
|
|
# and run the helper with the fake HOME, inside REPO_DIR (so `git config
|
|
# mosaic.gitIdentity` resolves per-worktree), plus any extra env passed in $@.
|
|
run_helper() {
|
|
local host="$1" username_in="$2"; shift 2
|
|
(
|
|
cd "$REPO_DIR"
|
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
|
|
bash "$HELPER" get <<EOF
|
|
host=$host
|
|
username=$username_in
|
|
|
|
EOF
|
|
)
|
|
}
|
|
|
|
# A refusal must be observable in four independent ways: nonzero exit, EMPTY
|
|
# stdout, a stderr diagnostic naming the identity and host, and — the assertion
|
|
# that actually catches a regression to the old behavior — NO shared token value
|
|
# anywhere in the output. Checking only the exit code would pass against a helper
|
|
# that emitted the shared credential and then exited 1.
|
|
assert_fail_closed() {
|
|
local desc="$1" host="$2" username_in="$3" want_in_stderr="$4"; shift 4
|
|
local stderr_file="$WORK_DIR/stderr.tmp"
|
|
: > "$stderr_file"
|
|
set +e
|
|
local stdout
|
|
stdout=$(run_helper "$host" "$username_in" "$@" 2>"$stderr_file")
|
|
local rc=$?
|
|
set -e
|
|
local stderr
|
|
stderr=$(cat "$stderr_file")
|
|
if [[ "$rc" -eq 0 ]]; then
|
|
echo "FAIL: $desc — expected nonzero exit, got 0 (stdout='$stdout')" >&2
|
|
fail=1
|
|
fi
|
|
if [[ -n "$stdout" ]]; then
|
|
echo "FAIL: $desc — expected empty stdout (nothing emitted), got '$stdout'" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$stdout$stderr" == *"shared-mosaicstack-token"* || "$stdout$stderr" == *"shared-usc-token"* ]]; then
|
|
echo "FAIL: $desc — a SHARED token value appeared in the output. The shared-account fallback must be gone:" >&2
|
|
echo "$stdout$stderr" >&2
|
|
fail=1
|
|
fi
|
|
if [[ -n "$want_in_stderr" && "$stderr" != *"$want_in_stderr"* ]]; then
|
|
echo "FAIL: $desc — stderr does not contain '$want_in_stderr':" >&2
|
|
echo "$stderr" >&2
|
|
fail=1
|
|
fi
|
|
if [[ "$stderr" != *"$host"* ]]; then
|
|
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
|
|
echo "$stderr" >&2
|
|
fail=1
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Backward compatibility: nothing resolvable, and NO fleet on this host ->
|
|
# shared account, unchanged. This is the only surviving fallback.
|
|
# ---------------------------------------------------------------------------
|
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
|
out=$(run_helper "git.mosaicstack.dev" "")
|
|
assert_eq "no identity + no fleet: username" "username=git" "$(echo "$out" | grep '^username=')"
|
|
assert_eq "no identity + no fleet: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. git-supplied username resolves to a SERVICE identity WITH a token in the
|
|
# framework store -> that identity + token wins over the shared account.
|
|
# ---------------------------------------------------------------------------
|
|
echo -n "agentA-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentA.token"
|
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
|
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
|
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
|
# ---------------------------------------------------------------------------
|
|
echo -n "agentB-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentB.token"
|
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
|
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
|
assert_eq "git-config beats username: password" "password=agentB-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
|
# ---------------------------------------------------------------------------
|
|
echo -n "agentC-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentC.token"
|
|
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
|
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
|
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Correct token PATH is chosen per host: same agent id, different host
|
|
# prefix (gitea-usc- vs gitea-mosaicstack-).
|
|
# ---------------------------------------------------------------------------
|
|
echo -n "agentD-usc-token" > "$SVC_STORE/gitea-usc-agentD.token"
|
|
out=$(run_helper "git.uscllc.com" "agentD")
|
|
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
|
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. FAIL CLOSED — identity resolves, no credential for it on this host. Must
|
|
# NOT borrow the shared account, and must NOT leak the same agent's token
|
|
# for a DIFFERENT host (agentD holds a usc token and no mosaicstack one).
|
|
# ---------------------------------------------------------------------------
|
|
assert_fail_closed "cross-host absence refuses (no shared fallback, no cross-host leak)" \
|
|
"git.mosaicstack.dev" "agentD" "gitea-mosaicstack-agentD.token"
|
|
# The agent's own usc token must not appear either.
|
|
: > "$WORK_DIR/stderr.tmp"
|
|
set +e
|
|
leak_out=$(run_helper "git.mosaicstack.dev" "agentD" 2>"$WORK_DIR/stderr.tmp")
|
|
set -e
|
|
if [[ "$leak_out$(cat "$WORK_DIR/stderr.tmp")" == *"agentD-usc-token"* ]]; then
|
|
echo "FAIL: cross-host leak — the usc token value appeared on a mosaicstack request" >&2
|
|
fail=1
|
|
fi
|
|
|
|
assert_fail_closed "unknown identity refuses (shared account never substituted)" \
|
|
"git.mosaicstack.dev" "no-such-agent" "no-token-for-identity"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. A refusal leaves a durable spool record, and that record contains no token.
|
|
# The stderr diagnostic is transient; the record is what an operator reads
|
|
# afterwards, so it must exist independently of anyone watching the terminal.
|
|
# ---------------------------------------------------------------------------
|
|
spool_file=$(find "$SPOOL_DIR" -maxdepth 1 -name '*.jsonl' | head -n 1)
|
|
if [[ -z "$spool_file" ]]; then
|
|
echo "FAIL: fail-closed left no spool record under $SPOOL_DIR" >&2
|
|
fail=1
|
|
else
|
|
spool_body=$(cat "$spool_file")
|
|
assert_eq "spool record names the refused identity" "1" \
|
|
"$(grep -c '"identity":"no-such-agent"' "$spool_file" | head -n 1)"
|
|
if [[ "$spool_body" == *"shared-"*"-token"* || "$spool_body" == *"agentD-usc-token"* ]]; then
|
|
echo "FAIL: spool record contains a token value:" >&2
|
|
echo "$spool_body" >&2
|
|
fail=1
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
|
# from its OWN secrets/ slot, not from the framework store.
|
|
# ---------------------------------------------------------------------------
|
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
|
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
|
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
|
assert_eq "seat reads its own slot: username" "username=seatE" "$(echo "$out" | grep '^username=')"
|
|
assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. NO CROSS-STORE FALLBACK — the assertion this whole store-selection design
|
|
# exists for. 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.
|
|
# The helper must refuse rather than read it: one credential, one location,
|
|
# and a seat that reads a same-named service credential is exactly the
|
|
# silent-substitution failure the fail-closed rule removes.
|
|
# ---------------------------------------------------------------------------
|
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
|
echo -n "seatF-SERVICE-STORE-token" > "$SVC_STORE/gitea-mosaicstack-seatF.token"
|
|
assert_fail_closed "seat with empty slot does NOT fall back to the framework store" \
|
|
"git.mosaicstack.dev" "seatF" "fleet/agents/seatF/secrets" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
|
: > "$WORK_DIR/stderr.tmp"
|
|
set +e
|
|
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
|
|
set -e
|
|
if [[ "$xstore_out$(cat "$WORK_DIR/stderr.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
|
echo "FAIL: cross-store fallback — a seat read 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. A non-seat identity pointed at
|
|
# the same file gets it.
|
|
out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
|
assert_eq "control — same file IS readable for a non-seat identity" \
|
|
"password=seatF-SERVICE-STORE-token" "$(echo "$out" | grep '^password=')"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. FAIL CLOSED — no identity resolvable, but this host runs a fleet. Where
|
|
# seats exist, an unattributable request is refused instead of receiving
|
|
# the shared account. Contrast with case 1, which is the same request on a
|
|
# host with no fleet and still returns the shared account.
|
|
# ---------------------------------------------------------------------------
|
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
|
assert_fail_closed "no identity on a fleet host refuses" \
|
|
"git.mosaicstack.dev" "" "no-identity" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. 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"
|
|
assert_fail_closed "fleet gate fires on the default ~/.mosaic brain home" \
|
|
"git.mosaicstack.dev" "" "no-identity"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 12. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
|
# remotes, e.g. github.com via a different credential helper). A fleet host
|
|
# must not refuse a host this helper does not own.
|
|
# ---------------------------------------------------------------------------
|
|
out=$(run_helper "github.com" "agentA")
|
|
assert_eq "unknown host: no output" "" "$out"
|
|
out=$(run_helper "github.com" "" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
|
assert_eq "unknown host on a fleet host: still passthrough, not a refusal" "" "$out"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 13. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
|
# protocol: this helper only implements get).
|
|
# ---------------------------------------------------------------------------
|
|
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
|
host=git.mosaicstack.dev
|
|
username=agentA
|
|
password=whatever
|
|
|
|
EOF
|
|
)
|
|
assert_eq "store verb: no output" "" "$store_out"
|
|
|
|
if [[ "$fail" -eq 0 ]]; then
|
|
echo "git-credential-mosaic identity resolution regression passed"
|
|
fi
|
|
|
|
exit "$fail"
|