fix(auth): mosaic-managed auth lives under the data root, never ~/.pi (#48)

Owner direction: the stack must never impact default harness usage.
Correction to M19 as shipped (nothing had been created in ~/.pi — the
move breaks nothing).

- Mosaic-managed accounts: <dataRoot>/auth/<account>.json, perms 0600
  enforced (loose perms flagged in listings, refused by --auth — mirrors
  gitea-api.sh credential hygiene).
- ~/.pi is read-only to the stack, permanently; the only interaction
  remains the existing read-only container mount of the default
  credential. Recorded as a ROADMAP standing decision.
- auth.sh is now config-driven (data root from config.json, fail closed,
  consistent with every other tool); status reports both sources labeled.
- agent.sh --auth resolution moved after load_config (needs the data
  root); missing/symlinked/non-0600 accounts refuse.
- test-auth.sh: 15 no-Docker cases (accounts-create-nothing, loose-perms
  refusal, invalid-config refusal added). Test-authoring correction
  recorded in BUILD-LOG (fixture-state mismatch caught before running).

Suites 24/15/90/14/17 + verify green.
This commit is contained in:
2026-09-03 22:53:33 -05:00
parent 073bbfdb6a
commit 975084abe2
9 changed files with 203 additions and 99 deletions
+66 -38
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bash
# Fast, sandboxed selftests for the auth checkpoint (M19).
# Fast, sandboxed selftests for the auth checkpoint (M19, corrected in #48).
#
# No Docker, no network, no real credentials: every case runs against
# fixture files via PI_AUTH_FILE. The suite asserts the core safety
# property: credential MATERIAL from fixtures never reaches auth.sh or
# agent.sh output.
# No Docker, no network, no real credentials. Mosaic-managed accounts live
# under the data root (config-driven, like every other tool); the default
# harness credential appears only as an explicit PI_AUTH_FILE fixture.
# The suite asserts the core safety property: credential MATERIAL from
# fixtures never reaches auth.sh or agent.sh output.
set -uo pipefail
cd "$(dirname "$0")/.."
@@ -27,22 +28,36 @@ check() {
SECRET="SK-TEST-DO-NOT-PRINT-9f2b"
TOKEN="ACCESS-TOKEN-SHOULD-NOT-PRINT"
ENVVAL="ENVVAL-SHOULD-NOT-PRINT"
mkdir -p "$SANDBOX/creds" "$SANDBOX/empty"
printf '{"anthropic":{"type":"oauth","access":"%s"},"zai":{"type":"api_key","key":"%s"}}' "$TOKEN" "$SECRET" > "$SANDBOX/creds/auth.json"
printf '{}' > "$SANDBOX/creds/auth.work.json"
printf '{}' > "$SANDBOX/creds/auth.personal.json"
mkdir -p "$SANDBOX/pi" "$SANDBOX/data/auth" "$SANDBOX/data/state" "$SANDBOX/empty"
# status: missing credential file -> exit 3 (config missing for a read)
OUT="$(PI_AUTH_FILE="$SANDBOX/creds/absent.json" scripts/auth.sh status 2>"$SANDBOX/err.txt")"; RC=$?
[ "$RC" -eq 3 ] && grep -q "credential file not found" "$SANDBOX/err.txt" \
&& check "status on missing credential file exits 3" 0 || check "status on missing credential file exits 3" 1
# mock system config: data root inside the sandbox (never the real one)
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}' "$SANDBOX/data" > "$SANDBOX/mock-config.json"
CFG="MOSAIC_CONFIG=$SANDBOX/mock-config.json"
# status: happy path — providers sorted with credential types
OUT="$(PI_AUTH_FILE="$SANDBOX/creds/auth.json" scripts/auth.sh status 2>&1)"; RC=$?
# release pointer so agent.sh's ensure stays on the no-op fast path
REL="$(tr -d '[:space:]' < RELEASE)"
printf '{"pointerVersion":1,"release":"%s","imageTag":"mosaic-poc-agent:0.84.4-r%s","activatedAt":"2026-01-01T00:00:00Z"}\n' "$REL" "$REL" > "$SANDBOX/data/state/active.json"
# fixtures: a default-harness credential and two mosaic-managed accounts
printf '{"anthropic":{"type":"oauth","access":"%s"},"zai":{"type":"api_key","key":"%s"}}' "$TOKEN" "$SECRET" > "$SANDBOX/pi/auth.json"
printf '{}' > "$SANDBOX/data/auth/work.json"
printf '{}' > "$SANDBOX/data/auth/personal.json"
chmod 600 "$SANDBOX/pi/auth.json" "$SANDBOX/data/auth/work.json" "$SANDBOX/data/auth/personal.json"
# status: missing default harness credential -> exit 3, accounts still listed
OUT="$(env PI_AUTH_FILE="$SANDBOX/pi/absent.json" $CFG scripts/auth.sh status 2>"$SANDBOX/err.txt")"; RC=$?
[ "$RC" -eq 3 ] \
&& printf '%s\n' "$OUT" | grep -q ' work (perms 600)' \
&& check "status with missing harness credential exits 3 and still lists accounts" 0 || check "status with missing harness credential exits 3 and still lists accounts" 1
# status: happy path — harness providers + mosaic accounts, both labeled
OUT="$(env PI_AUTH_FILE="$SANDBOX/pi/auth.json" $CFG scripts/auth.sh status 2>&1)"; RC=$?
[ "$RC" -eq 0 ] \
&& printf '%s\n' "$OUT" | grep -q 'read-only to the stack' \
&& printf '%s\n' "$OUT" | grep -q ' anthropic type=oauth source=auth\.json' \
&& printf '%s\n' "$OUT" | grep -q ' zai type=api_key source=auth\.json' \
&& check "status lists providers with credential types" 0 || check "status lists providers with credential types" 1
&& printf '%s\n' "$OUT" | grep -q 'mosaic-managed accounts' \
&& check "status reports harness credential (read-only) + mosaic accounts" 0 || check "status reports harness credential (read-only) + mosaic accounts" 1
# the core safety property: fixture secret material never reaches output
printf '%s\n' "$OUT" | grep -q "$SECRET" \
@@ -50,48 +65,61 @@ printf '%s\n' "$OUT" | grep -q "$SECRET" \
printf '%s\n' "$OUT" | grep -q "$TOKEN" \
&& check "oauth token material never reaches output" 1 || check "oauth token material never reaches output" 0
# status: unparseable file -> exit 2
printf 'not json' > "$SANDBOX/creds/broken.json"
PI_AUTH_FILE="$SANDBOX/creds/broken.json" scripts/auth.sh status >/dev/null 2>&1; RC=$?
# status: unparseable harness file -> exit 2
printf 'not json' > "$SANDBOX/pi/broken.json"
env PI_AUTH_FILE="$SANDBOX/pi/broken.json" $CFG scripts/auth.sh status >/dev/null 2>&1; RC=$?
[ "$RC" -eq 2 ] && check "unparseable credential file exits 2" 0 || check "unparseable credential file exits 2" 1
# status: symlinked credential file -> exit 4 (must be a regular file)
ln -s "$SANDBOX/creds/auth.json" "$SANDBOX/creds/link.json"
PI_AUTH_FILE="$SANDBOX/creds/link.json" scripts/auth.sh status >/dev/null 2>&1; RC=$?
# status: symlinked harness file -> exit 4
ln -s "$SANDBOX/pi/auth.json" "$SANDBOX/pi/link.json"
env PI_AUTH_FILE="$SANDBOX/pi/link.json" $CFG scripts/auth.sh status >/dev/null 2>&1; RC=$?
[ "$RC" -eq 4 ] && check "symlinked credential file exits 4" 0 || check "symlinked credential file exits 4" 1
# status: env-side names are informational — name shown, value never
OUT="$(FAKE_TEST_API_KEY=$ENVVAL PI_AUTH_FILE="$SANDBOX/creds/auth.json" scripts/auth.sh status 2>&1)"
# status: env-side names informational — name shown, value never
OUT="$(env FAKE_TEST_API_KEY=$ENVVAL PI_AUTH_FILE="$SANDBOX/pi/auth.json" $CFG scripts/auth.sh status 2>&1)"
printf '%s\n' "$OUT" | grep -q "FAKE_TEST_API_KEY" \
&& check "env-side credential names reported" 0 || check "env-side credential names reported" 1
printf '%s\n' "$OUT" | grep -q "$ENVVAL" \
&& check "env var values never reach output" 1 || check "env var values never reach output" 0
# accounts: empty directory -> (none), exit 0
OUT="$(PI_AUTH_FILE="$SANDBOX/empty/auth.json" scripts/auth.sh accounts 2>&1)"; RC=$?
[ "$RC" -eq 0 ] && printf '%s\n' "$OUT" | grep -q "(none)" \
&& check "accounts with no named files reports none" 0 || check "accounts with no named files reports none" 1
# accounts: mosaic-managed, under the data root — a data root with no accounts dir is the default state: (none), exit 0, creates nothing
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}' "$SANDBOX/empty" > "$SANDBOX/mock-config-empty.json"
OUT="$(env PI_AUTH_FILE="$SANDBOX/pi/auth.json" MOSAIC_CONFIG="$SANDBOX/mock-config-empty.json" scripts/auth.sh accounts 2>&1)"; RC=$?
[ "$RC" -eq 0 ] && printf '%s\n' "$OUT" | grep -q '(none)' && [ ! -d "$SANDBOX/empty/auth" ] \
&& check "accounts without an accounts dir reports none and creates nothing" 0 || check "accounts without an accounts dir reports none and creates nothing" 1
# accounts: listing with active marker
OUT="$(PI_AUTH_FILE="$SANDBOX/creds/auth.work.json" scripts/auth.sh accounts 2>&1)"; RC=$?
[ "$RC" -eq 0 ] && printf '%s\n' "$OUT" | grep -q '^ work <- active (PI_AUTH_FILE)$' \
&& printf '%s\n' "$OUT" | grep -q '^ personal$' \
OUT="$(env PI_AUTH_FILE="$SANDBOX/data/auth/work.json" $CFG scripts/auth.sh accounts 2>&1)"; RC=$?
printf '%s\n' "$OUT" | grep -q '^ work (perms 600) <- active (PI_AUTH_FILE)$' \
&& printf '%s\n' "$OUT" | grep -q '^ personal (perms 600)$' \
&& check "accounts lists files and marks the active one" 0 || check "accounts lists files and marks the active one" 1
# accounts: missing directory -> exit 3
PI_AUTH_FILE="$SANDBOX/nodir/auth.json" scripts/auth.sh accounts >/dev/null 2>&1; RC=$?
[ "$RC" -eq 3 ] && check "accounts on missing directory exits 3" 0 || check "accounts on missing directory exits 3" 1
# accounts: loose perms flagged loudly
printf '{}' > "$SANDBOX/data/auth/loose.json"
chmod 644 "$SANDBOX/data/auth/loose.json"
OUT="$(env $CFG scripts/auth.sh accounts 2>&1)"
printf '%s\n' "$OUT" | grep -q 'loose (perms 644) \[not 0600 — agent --auth will refuse\]' \
&& check "loose account perms flagged in listing" 0 || check "loose account perms flagged in listing" 1
# agent.sh --auth: missing account file refuses before any container work
scripts/agent.sh researcher --auth nope </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$?
[ "$RC" -eq 4 ] && grep -q "no credential file for auth account 'nope'" "$SANDBOX/err.txt" \
# agent.sh --auth: missing mosaic-managed account file refuses (pre-container)
env $CFG scripts/agent.sh researcher --auth nope </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$?
[ "$RC" -eq 4 ] && grep -q "no mosaic-managed credential file for auth account 'nope'" "$SANDBOX/err.txt" \
&& check "agent --auth with missing account file refuses (exit 4)" 0 || check "agent --auth with missing account file refuses (exit 4)" 1
# agent.sh --auth: loose perms refuse (0600 required, mirroring gitea-api.sh)
env $CFG scripts/agent.sh researcher --auth loose </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$?
[ "$RC" -eq 4 ] && grep -q "must be 0600 (got 644)" "$SANDBOX/err.txt" \
&& check "agent --auth with non-0600 account file refuses" 0 || check "agent --auth with non-0600 account file refuses" 1
# agent.sh --auth: invalid account name refuses
scripts/agent.sh researcher --auth "bad~name" </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$?
env $CFG scripts/agent.sh researcher --auth "bad~name" </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$?
[ "$RC" -eq 4 ] && grep -q "invalid auth account name" "$SANDBOX/err.txt" \
&& check "agent --auth with invalid account name refuses" 0 || check "agent --auth with invalid account name refuses" 1
# auth.sh: invalid config refuses (fail closed, like every tool)
MOSAIC_CONFIG="$SANDBOX/no-such-config.json" scripts/auth.sh status >/dev/null 2>&1; RC=$?
[ "$RC" -ne 0 ] && check "auth.sh without valid config refuses" 0 || check "auth.sh without valid config refuses" 1
echo
echo "selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]