git credentials: fail closed, and read a seat's token from its own slot
ci/woodpecker/pr/ci Pipeline failed
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:
@@ -1,16 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
||||
# resolution (Gate-16 author≠reviewer separation).
|
||||
# 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 per-slot token file path chosen per host
|
||||
# 2. Correct token file path chosen per host
|
||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||
# 3. Per-slot token present -> emits that identity + token.
|
||||
# 4. Per-slot token absent -> falls back to the shared account
|
||||
# (backward-compat / no-op for hosts without per-slot tokens).
|
||||
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||
# 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.
|
||||
@@ -21,6 +31,9 @@ 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
|
||||
@@ -28,10 +41,10 @@ REPO_DIR="$WORK_DIR/repo"
|
||||
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
||||
mkdir -p "$SVC_STORE" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||
"$REPO_DIR"
|
||||
"$REPO_DIR" "$BRAIN_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||
chmod +x "$HELPER"
|
||||
@@ -68,7 +81,8 @@ run_helper() {
|
||||
local host="$1" username_in="$2"; shift 2
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
|
||||
bash "$HELPER" get <<EOF
|
||||
host=$host
|
||||
username=$username_in
|
||||
|
||||
@@ -76,20 +90,61 @@ 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. No identity resolvable anywhere, no per-slot token -> shared fallback
|
||||
# (backward-compat: unchanged behavior when nothing is configured).
|
||||
# 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 "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
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 an identity WITH a per-slot token ->
|
||||
# that identity + token wins over the shared account.
|
||||
# 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" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||
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=')"
|
||||
@@ -97,7 +152,7 @@ assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-to
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||
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=')"
|
||||
@@ -106,44 +161,132 @@ assert_eq "git-config beats username: password" "password=agentB-mosaicstack-tok
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
|
||||
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. Identity resolves, but no matching per-slot token file -> falls back to
|
||||
# the shared account (per-agent identity is opt-in, not a hard requirement).
|
||||
# 5. Correct token PATH is chosen per host: same agent id, different host
|
||||
# prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||
# ---------------------------------------------------------------------------
|
||||
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
||||
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
||||
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||
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=')"
|
||||
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
|
||||
# leak the usc token across hosts.
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentD")
|
||||
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||
# remotes, e.g. github.com via a different credential helper).
|
||||
# 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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||
# protocol: this helper only implements get).
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user