fix(mosaic): close credential evidence gaps

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent d18e49e8f4
commit 26203bd92c
16 changed files with 373 additions and 85 deletions
@@ -499,6 +499,15 @@ get_gitea_url_for_host() {
# Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
_trace_credential_resolution() {
[[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]] || return 0
local resolution_path="$1" reason="$2" identity="$3" host="$4" source="$5"
local shared_path_entered=false
[[ "$resolution_path" == shared ]] && shared_path_entered=true
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \
"$reason" "$identity" "$host" "$resolution_path" "$shared_path_entered" "$source" >&2
}
get_gitea_token() {
local host="$1"
local script_dir
@@ -532,26 +541,14 @@ get_gitea_token() {
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
if [[ -r "$_idcred" ]]; then
local _resolved_token
_resolved_token=$(python3 - "$_idcred" <<'PY'
import json, sys
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
raise SystemExit(1)
print(value)
PY
) || return 1
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$host" "$_ident_src" >&2
fi
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
"$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
_trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src"
printf '%s\n' "$_resolved_token"
return 0
fi
if [[ -r "$_idtok" ]]; then
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$host" "$_ident_src" >&2
fi
_trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src"
cat "$_idtok"
return 0
fi
@@ -607,6 +604,7 @@ PY
echo "${GITEA_TOKEN:-}"
)
if [[ -n "$token" ]]; then
_trace_credential_resolution shared shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
echo "$token"
return 0
fi
@@ -615,6 +613,7 @@ PY
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
if [[ -n "${GITEA_TOKEN:-}" ]]; then
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
_trace_credential_resolution shared shared-credential-resolved '<interactive-shared>' "$host" environment
echo "$GITEA_TOKEN"
return 0
fi
@@ -626,6 +625,7 @@ PY
local token
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
if [[ -n "$token" ]]; then
_trace_credential_resolution shared shared-credential-resolved '<interactive-shared>' "$host" git-credentials
echo "$token"
return 0
fi
@@ -24,6 +24,15 @@ while IFS= read -r line; do
username=*) username_in=${line#username=};;
esac
done
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
trace_resolution() {
[ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ] || return 0
resolution_path="$1" reason="$2" trace_identity="$3" trace_host="$4" source="$5"
shared_path_entered=false
[ "$resolution_path" = shared ] && shared_path_entered=true
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \
"$reason" "$trace_identity" "$trace_host" "$resolution_path" "$shared_path_entered" "$source" >&2
}
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
# survives across non-persistent shells) > git-supplied username (credential.username
@@ -48,27 +57,15 @@ if [ -n "$ident" ]; then
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
if [ -r "$idcred" ]; then
token=$(python3 - "$idcred" <<'PY'
import json, sys
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
raise SystemExit(1)
print(value)
PY
) || exit 1
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$host" >&2
fi
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
"$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
echo "username=${ident}"
echo "password=${token}"
exit 0
fi
if [ -r "$idtok" ]; then
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$host" >&2
fi
trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
echo "username=${ident}"
echo "password=$(cat "$idtok")"
exit 0
@@ -98,10 +95,10 @@ esac
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
# of where the framework installer places tools/ under $HOME — mirrors
# detect-platform.sh's own cred_loader resolution in this same directory.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh"
load_credentials "$svc" >/dev/null 2>&1 || exit 0
trace_resolution shared shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
# GITEA_USER is not populated by load_credentials (it only exports
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
# git-over-HTTP auth authenticates from the token itself (the password field),
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Fail-closed reader for one governed Mosaic credential envelope."""
import hashlib
import json
import os
import stat
import sys
MAX_BYTES = 64 * 1024
EXPECTED_KEYS = {
"schemaVersion",
"identity",
"estate",
"host",
"providerLogin",
"tokenName",
"scopes",
"createdAt",
"tokenDigest",
"token",
}
def refuse(message: str) -> None:
print(f"credential envelope refused: {message}", file=sys.stderr)
raise SystemExit(1)
if len(sys.argv) != 5:
refuse("expected path, identity, estate, and host")
path, identity, estate, host = sys.argv[1:]
if not estate:
refuse("explicit estate is required")
parent = os.path.dirname(path)
try:
parent_stat = os.stat(parent, follow_symlinks=False)
except OSError:
refuse("credential directory unavailable")
if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode):
refuse("credential directory is not a real directory")
if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022:
refuse("credential directory owner or mode is unsafe")
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError:
refuse("credential file unavailable or symbolic")
try:
file_stat = os.fstat(fd)
if not stat.S_ISREG(file_stat.st_mode):
refuse("credential is not a regular file")
if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077:
refuse("credential owner or mode is unsafe")
content = os.read(fd, MAX_BYTES + 1)
if len(content) > MAX_BYTES:
refuse("credential exceeds size limit")
finally:
os.close(fd)
try:
value = json.loads(content)
except (UnicodeDecodeError, json.JSONDecodeError):
refuse("credential is not valid JSON")
if not isinstance(value, dict) or set(value) != EXPECTED_KEYS:
refuse("credential schema is not exact")
if (
value.get("schemaVersion") != 1
or value.get("identity") != identity
or value.get("estate") != estate
or value.get("host") != host
or value.get("providerLogin") != identity
):
refuse("credential binding does not match requested identity, estate, host, and principal")
token = value.get("token")
if not isinstance(token, str) or not token or any(ch.isspace() for ch in token):
refuse("credential token is invalid")
if value.get("tokenDigest") != hashlib.sha256(token.encode()).hexdigest():
refuse("credential digest does not match token")
sys.stdout.write(token + "\n")
@@ -34,6 +34,7 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
"$REPO_DIR"
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py"
chmod +x "$HELPER"
git -C "$REPO_DIR" init -q
@@ -84,6 +85,12 @@ 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=')"
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/shared-trace.stderr")
err=$(cat "$WORK_DIR/shared-trace.stderr")
if [[ "$err" != *"resolution_path=shared"* || "$err" != *"shared_path_entered=true"* ]]; then
echo "FAIL: shared credential materialization did not emit its computed path" >&2
fail=1
fi
set +e
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr")
@@ -103,6 +110,12 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
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=')"
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentA MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/identity-trace.stderr")
err=$(cat "$WORK_DIR/identity-trace.stderr")
if [[ "$err" != *"resolution_path=identity"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: identity credential did not emit its computed path" >&2
fail=1
fi
set +e
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
@@ -164,14 +177,42 @@ assert_eq "host-scoped token path (cross-host must not leak): username" "usernam
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
# 7. Governed envelopes use the same binding, owner, mode, and digest checks.
# ---------------------------------------------------------------------------
envelope="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.credential.json"
python3 - "$envelope" <<'PY'
import hashlib, json, sys
secret = "agentE-envelope-token"
json.dump({
"schemaVersion": 1, "identity": "agentE", "estate": "homelab",
"host": "git.mosaicstack.dev", "providerLogin": "agentE",
"tokenName": "mosaic-agentE-1", "scopes": ["write:repository"],
"createdAt": "2026-08-05T00:00:00.000Z",
"tokenDigest": hashlib.sha256(secret.encode()).hexdigest(), "token": secret,
}, open(sys.argv[1], "w", encoding="utf-8"))
PY
chmod 600 "$envelope"
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab)
assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')"
chmod 640 "$envelope"
set +e
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr")
rc=$?
set -e
if [[ "$rc" -eq 0 || "$out" == *"password="* ]]; then
echo "FAIL: permissive envelope was consumed" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 8. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
# remotes, e.g. github.com via a different credential helper).
# ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out"
# ---------------------------------------------------------------------------
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
# 9. 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