diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 729c5e32..377c913c 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -43,7 +43,7 @@ fleet commits, pushes, and opens PRs under one identity — with no cryptographi separation between an author and a reviewer. Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent -identity** before falling back to the shared account: +identity**: 1. `MOSAIC_GIT_IDENTITY` environment variable, or 2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across @@ -51,12 +51,54 @@ identity** before falling back to the shared account: 3. (git-credential-mosaic only) the username git itself supplies for the credential request. -If the resolved identity has a token file at -`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-.token`, that -identity + token is used. **Nothing configured → nothing changes**: with no per-slot -token file present, both tools fall through to the existing shared-account path -unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot -tokens. +### Which store a credential is read from + +The store is chosen by what the identity **is**, not by which file happens to exist first: + +| The identity | Its credential is read from | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| has a directory at `/fleet/agents//` — it is a **seat** | `/fleet/agents//secrets/gitea-{usc,mosaicstack}-.token` | +| does not — it is a **service identity** | `~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-.token` | + +`` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution +`packages/mosaic/src/fleet/brain-home.ts` performs. + +**There is no precedence between the two stores and no fallback from one to the other.** +A seat whose slot is empty is refused even when a same-named token sits in the framework +store. One credential lives in exactly one location: a second copy is drift rather than +redundancy, and the way drift surfaces is a stale copy returning 401, which reads as a +revoked token and sends whoever debugs it to the wrong place. + +### What happens when nothing resolves + +| identity resolves | token in its store | host runs a fleet | result | +| ----------------- | ------------------ | ----------------- | ------------------------- | +| yes | yes | — | that identity + token | +| yes | no | — | **fail closed** | +| no | — | yes | **fail closed** | +| no | — | no | shared account, unchanged | + +A host "runs a fleet" when `/fleet/agents` exists — the same signal `brain-home.ts` +uses to decide a brain is active. + +Failing closed means: nothing is emitted, the exit status is nonzero, a stderr diagnostic +names the identity, its source, the store it resolved to and the path that was expected, +and `git-credential-mosaic` additionally appends a record (identity, host, reason, cwd — +never a token value) to `${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}`. +The git operation fails; nothing is attributed to anyone. + +The shared-account fallback that used to cover these two cases is why a PR could be +authored, commented and merged under an account whose owner did not open it — every seat +shared one identity, so the record could not be traced back afterwards. An +under-provisioned agent is refused rather than handed the most privileged account +available. + +**On a host with no fleet, nothing changes**: no `fleet/agents` directory means the shared +account still answers, so this is a no-op for an operator who has not provisioned per-slot +tokens. On a host that does run a fleet, a human doing manual git work needs an identity +of their own — `MOSAIC_GIT_IDENTITY=` with a provisioned slot. There is deliberately no +environment variable that restores the fallback; one would reintroduce exactly the +substitution this removes. ### Enabling it for a clone diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 97c6e5b3..89a5c3a3 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -507,37 +507,69 @@ get_gitea_token() { # 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the # per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a - # stored per-slot token for this host, act AS that agent so API tooling + # stored credential for this host, act AS that agent so API tooling # (pr-create, issue-create, …) authors under the right identity — matching the - # git credential helper. Backward-compatible: nothing resolvable → shared logic below. + # git credential helper, which this block deliberately mirrors. local _ident="${MOSAIC_GIT_IDENTITY:-}" local _ident_src="MOSAIC_GIT_IDENTITY" if [[ -z "$_ident" ]]; then _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" _ident_src="git config mosaic.gitIdentity" fi - if [[ -n "$_ident" ]]; then - local _idpfx="" - case "$host" in - git.uscllc.com) _idpfx=gitea-usc ;; - git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;; - esac - if [[ -n "$_idpfx" ]]; then - local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token" - if [[ -r "$_idtok" ]]; then - cat "$_idtok" - return 0 - fi - # FAIL LOUD: an explicit git identity was requested for a recognized Gitea host, - # but no per-slot token exists for THAT identity. Refuse to fall through to the - # shared/default credential loader below — silently borrowing another slot's token - # would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed - # to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the - # caller aborts loudly rather than acting as the wrong identity. - echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2 - echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2 - return 1 + + # Recognized Gitea hosts are the ones carrying the per-identity token scheme. + local _idpfx="" + case "$host" in + git.uscllc.com) _idpfx=gitea-usc ;; + git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;; + esac + + # Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and + # tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic. + local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" + + if [[ -n "$_ident" && -n "$_idpfx" ]]; then + # Credential store selection: an identity is a SEAT or a SERVICE, and which + # one it is decides where its credential lives. No precedence between the + # two stores and no fallback from one to the other — a seat with an empty + # slot fails loud rather than reading a service credential of the same name. + # One credential, one location: two copies diverge, and the stale copy fails + # in a way that reads as a revoked token rather than as drift. + local _idtok _ident_kind + 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="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token" fi + if [[ -r "$_idtok" ]]; then + cat "$_idtok" + return 0 + fi + # FAIL LOUD: an explicit git identity was requested for a recognized Gitea host, + # but no credential exists for THAT identity. Refuse to fall through to the + # shared/default credential loader below — silently borrowing another identity's + # token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review + # attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop + # instead so the caller aborts loudly rather than acting as the wrong identity. + echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2 + echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2 + echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2 + return 1 + fi + + # FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist, + # every record must name the agent that made it, so an unattributable request is + # refused rather than handed the shared account. `fleet/agents` existing is the + # same signal brain-home.ts uses to decide a brain is active. A host with no fleet + # keeps the shared path below unchanged: there the shared account is the operator's + # own and there is no attribution to lose. + if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then + echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2 + echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2 + echo " Set MOSAIC_GIT_IDENTITY= or 'git config mosaic.gitIdentity '." >&2 + return 1 fi # 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env) diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 915fc7bc..931a70df 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -1,21 +1,48 @@ #!/bin/bash -# git-credential-mosaic — git credential helper — resolves Gitea tokens from -# the Mosaic credential store at runtime so remote URLs never embed secrets. +# 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" -# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic" # -# Per-agent Gate-16 identity (author != reviewer separation): +# Per-agent identity (Gate-16 author != reviewer separation): # git config mosaic.gitIdentity # per-worktree, persists on disk # # or: export MOSAIC_GIT_IDENTITY= # -# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity -# (per-worktree, survives across non-persistent shells) > git-supplied username -# (credential.username / URL). When the resolved identity has a matching -# per-agent token file, use it instead of the shared account. Backward -# compatible: nothing resolvable -> shared token (unchanged behavior). +# ── 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 @@ -24,46 +51,170 @@ while IFS= read -r line; do username=*) username_in=${line#username=};; esac done -# 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 -# / URL). When the resolved identity has a matching per-agent token, use it instead of -# the shared account. Backward-compatible: nothing resolvable → shared token. -ident="$MOSAIC_GIT_IDENTITY" -[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) -[ -z "$ident" ] && ident="$username_in" -if [ -n "$ident" ]; then - case "$host" in - git.uscllc.com) idpfx=gitea-usc;; - git.mosaicstack.dev) idpfx=gitea-mosaicstack;; - *) idpfx="";; - esac - if [ -n "$idpfx" ]; then - idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token" - if [ -r "$idtok" ]; then - echo "username=${ident}" - echo "password=$(cat "$idtok")" - exit 0 - fi - fi -fi + +# 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) svc=gitea-usc;; - git.mosaicstack.dev) svc=gitea-mosaicstack;; + git.uscllc.com) idpfx=gitea-usc;; + git.mosaicstack.dev) idpfx=gitea-mosaicstack;; *) exit 0;; 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 -# 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), -# not from the username string, so any non-empty placeholder works here — this -# is deliberately NOT a real account name (framework files must stay -# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh). -echo "username=${GITEA_USER:-git}" -echo "password=$GITEA_TOKEN" + +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 — /fleet/agents// exists +# credential at /fleet/agents//secrets/-.token +# service — it does not +# credential at ~/.config/mosaic/secrets/gitea-tokens/-.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. +# +# A record field is arbitrary operator-supplied text: an identity comes from git +# config or the environment, and cwd is whatever directory git ran in. Either can +# contain a quote or a backslash, which would make the line unparseable JSON -- +# and a spool that silently stops parsing is worse than no spool, because the +# operator only discovers it while reading the record that explains an outage. +json_escape() { + local s=$1 + s=${s//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\t'/\\t} + s=${s//$'\r'/\\r} + s=${s//$'\n'/\\n} + printf '%s' "$s" +} + +spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}" +spool_record="" +if mkdir -p "$spool" 2>/dev/null; then + chmod 700 "$spool" 2>/dev/null + spoolfile="$spool/$(date -u +%Y%m%d).jsonl" + dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)" + if [ ! -e "$dedupe" ]; then + : > "$dedupe" 2>/dev/null + printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \ + "$(json_escape "$ts")" "$(json_escape "$reason")" \ + "$(json_escape "${ident:-}")" "$(json_escape "$ident_src")" \ + "$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \ + "$(json_escape "$host")" "$(json_escape "$PWD")" \ + >> "$spoolfile" 2>/dev/null + chmod 600 "$spoolfile" 2>/dev/null + fi + # Name the record only if one is actually on disk. Printing the path + # unconditionally sends the operator to a file that does not exist on exactly + # the hosts where the spool could not be created. + [ -s "$spoolfile" ] && spool_record="$spoolfile" + find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null +fi + +cat >&2 <}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})} + reason : ${reason} +EOF + +if [ -n "$ident" ]; then + cat >&2 <&2 < # process-scoped + git config mosaic.gitIdentity # 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. + +EOF + +if [ -n "$spool_record" ]; then + echo " record: ${spool_record}" >&2 +else + echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2 +fi +exit 1 diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh index e84778b6..6c18c3cd 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -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-.token vs gitea-mosaicstack-.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 +# /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 < "$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,54 +161,210 @@ 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 /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 </dev/null 2>&1 +host=git.mosaicstack.dev +username=no-such-agent + +EOF +) || true +# Deliberately not `ls ... | head -1`: under `set -o pipefail` a missed glob +# makes ls exit 2, the pipeline inherits it, and `set -e` kills this suite with +# zero output — the same silent-nonzero failure this file exists to catch. +record_file="" +for candidate in "$hostile_spool"/*.jsonl; do + if [[ -e "$candidate" ]]; then + record_file="$candidate" + break + fi +done +if [[ -z "$record_file" ]]; then + echo "FAIL: hostile cwd — no escalation record was written at all" >&2 + fail=1 +elif ! python3 -c 'import json,sys +for line in open(sys.argv[1]): + line = line.strip() + if line: + json.loads(line)' "$record_file" 2>/dev/null; then + echo "FAIL: hostile cwd — escalation record is not parseable JSONL:" >&2 + cat "$record_file" >&2 + fail=1 +fi + +# --------------------------------------------------------------------------- +# 15. When the spool cannot be created, the diagnostic must NOT name a record +# path. Naming a file that was never written sends the operator to an +# empty path on exactly the hosts where the escalation was lost. +# --------------------------------------------------------------------------- +unwritable_spool="/proc/mosaic-credential-spool-cannot-exist" +nospool_err=$( + cd "$REPO_DIR" + env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \ + MOSAIC_GIT_IDENTITY=no-such-agent \ + bash "$HELPER" get <&1 >/dev/null +host=git.mosaicstack.dev +username=no-such-agent + +EOF +) || true +if [[ "$nospool_err" == *"record: $unwritable_spool/"* ]]; then + echo "FAIL: unwritable spool — diagnostic names a record file that was never written" >&2 + fail=1 +fi +if [[ "$nospool_err" != *"NOT WRITTEN"* ]]; then + echo "FAIL: unwritable spool — diagnostic does not say the record was not written" >&2 + echo "$nospool_err" >&2 + fail=1 +fi + if [[ "$fail" -eq 0 ]]; then echo "git-credential-mosaic identity resolution regression passed" fi diff --git a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh index d56e7bee..bc049b61 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh @@ -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 +# /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 /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