Skip to content

feat(crosscheck): project one Pi profile into the account home its consumers read - #248

Merged
ruby-dlee merged 3 commits into
mainfrom
fm/pi-account-home-projection
Aug 18, 2026
Merged

feat(crosscheck): project one Pi profile into the account home its consumers read#248
ruby-dlee merged 3 commits into
mainfrom
fm/pi-account-home-projection

Conversation

@ruby-dlee

Copy link
Copy Markdown
Owner

The mismatch

Pi keeps every signed-in profile in one auth.json, keyed by provider slot (openai-codex, openai-codex-2, ...). Every Firstmate consumer instead reads an account home holding exactly one credential under the fixed key openai-codexfm-crosscheck.py: inspect_pi_credential, account_identity, and fm-crosscheck-azure.py: create_credential_archive all name that key literally.

Pointing a reviewer at the pooled file fails two ways at once:

  1. Only the first slot is ever read, so profiles 2..N are unreachable no matter which one the roster selected.
  2. The Azure reviewer credential archive carries every signed-in account's tokens into a compartment that needs exactly one.

The operator pool holds 8 profiles across 8 distinct upstream accounts in 16,846 bytes. MAX_CONFIG_BYTES is 65,536, so no existing bound catches it.

What this adds

bin/fm-pi-account-home.py writes the single-profile homes those consumers already expect.

bin/fm-pi-account-home.py report

It validates credential shape, refuses a non-oauth or blanked profile, writes 0600 under a 0700 directory via a private temp file and a rename, and refuses to follow a symlink at the credential path. It reports expiry instants and account digests, never token material.

It deliberately does not decide whether a credential is still good enough to use. That question has one owner, bin/fm-credential-expiry.py, which the callers run as their preflight. Duplicating the judgement here would give one fact two owners.

--all refuses as a set rather than leaving a half-projected root behind, and names every unusable profile at once so an operator fixes one round of logins instead of discovering the next broken profile one failed projection at a time.

Test

The second unit drives the real fm-crosscheck.py reader against a projected home and asserts the identity it derives, rather than restating the key name. Asserting the key name would only prove the test agrees with itself.

mutation result
entry written under its pool name instead of openai-codex red
blank-token check disabled red
credential mode widened to 0644 red
symlink guard removed red
--all downgraded to skip-and-continue red

Each was confirmed to change the file before the run. shellcheck -x exits 0; bin/fm-behavior-shards.sh --check 8 plans 113 tests.

Note on model independence

The 8 profiles are 8 distinct upstream accounts. Account separation is therefore already satisfied for a Pi-versus-Pi review; config/crosscheck-same-model relaxes only the model screen, not the account screen.

@ruby-dlee
ruby-dlee force-pushed the fm/pi-account-home-projection branch 2 times, most recently from 06a5459 to 2e2a174 Compare August 18, 2026 20:35
…nsumers read

Pi keeps every signed-in profile in one auth.json keyed by provider slot:
openai-codex, openai-codex-2, and so on. Every Firstmate consumer of a Pi
credential instead reads an account home holding exactly one credential under
the fixed key openai-codex. fm-crosscheck.py's inspect_pi_credential and
account_identity both name that key literally, and so does the Azure Crosscheck
credential archive.

Pointing a reviewer at the pooled file therefore fails two ways at once. Only
the first slot is ever read, so profiles 2..N are unreachable no matter which
one the roster selected; and the reviewer archive carries every signed-in
account's tokens into a compartment that needs exactly one. The operator pool
here holds eight profiles across eight distinct upstream accounts in 16,846
bytes, which is under MAX_CONFIG_BYTES, so no existing bound catches it.

This writes the single-profile homes those consumers already expect. It
validates credential shape, refuses a non-oauth or blanked profile, writes
0600 under a 0700 directory through a private temp file and a rename, and
refuses to follow a symlink at the credential path. It reports expiry instants
and account digests and never prints token material.

It does not decide whether a credential is good enough to use. That question
has one owner, bin/fm-credential-expiry.py, which the callers run as their
preflight; duplicating the judgement here would give one fact two owners.

--all refuses as a set rather than leaving a half-projected root behind, and
names every unusable profile at once so an operator fixes one round of logins
instead of discovering the next broken profile one failed projection at a time.

Verification: the second test unit drives the real fm-crosscheck.py reader
against a projected home rather than restating the key name, so it proves the
consumer accepts the output instead of proving the test agrees with itself.
Five mutations, each confirmed to change the file first, all red: the entry
written under its pool name, the blank-token check disabled, the mode widened
to 0644, the symlink guard removed, and --all downgraded to skip-and-continue.
…n writing outside its root

An adversarial review found two blockers and a hazard the change did not close.

The test never routed through tests/test-entry.sh, so it bypassed the runner and
the admission seal refused it: test-suite-seal.test.sh exits 97 on this branch
and passes on main. I ran the new test file alone and never ran the suite, which
is how a red seal reached CI.

The symlink guard was defeated one directory level up. It lstat'd the credential
path but not the profile directory holding it, and mkdir(exist_ok=True) succeeds
on a symlink to a directory because isdir follows it. A planted link at
<root>/<profile> therefore took a live OAuth token outside the destination root
and the tool reported success. Compounding it, Path.mkdir(parents=True) applies
its mode to the leaf only, so every ancestor landed at the caller's umask - 0777
under a permissive one, which is the precondition for planting that link.

Now every component the tool creates is made owner-only regardless of umask, a
non-directory or symlinked profile component is refused, and a pre-existing
group- or world-writable root without the sticky bit is refused outright: a
credential is not written under a path others can replace between the check and
the write.

The larger point: this was an optional script with no refusal at the consumer,
so the hazard it exists to remove stayed fully reachable for anyone who did not
run it. A pooled auth.json passed inspect_pi_credential, and the Azure archive's
same-account guard compares slot one against an identity derived from slot one,
so it always agreed with itself and staged all eight accounts. The reader now
refuses an account home carrying more than one provider slot, which closes both
halves unconditionally and makes the projection the remedy rather than the hope.

Also closed, all found by the same review: the source symlink guard was dead
code because both callers resolve() before reading; a lone unknown --profile was
refused by the empty-selection guard rather than by the missing-name check, so
a typo mixed with a good name was silently dropped; entry_faults advertised
every fault and only the first was asserted; the reported expiry instant, the
distinct-account count, and the account digest were printed but never read by a
test; the pool size bound was untested; and an ordinary OSError escaped the
refusal contract as a traceback.

Mutations, each confirmed to change the file first, now red: first-fault-only,
unknown profiles dropped, source symlink accepted, the size bound removed, the
expiry instant shifted, the raw account id returned instead of a digest, and
distinct-accounts reporting the total.

Not claimed: the temp-file-and-rename is argued from the mechanism, not proven.
An assertion I wrote for it passed identically with a direct write, so it was
removed rather than shipped, and the comment now says which half is covered.
…at flag

CI caught what local runs could not. `stat -f FMT` is a BSD format string on
macOS and the GNU flag for "display filesystem status" on Linux, so the
`stat -f ... || stat -c ...` pair never fell through: on the runner `stat -f`
SUCCEEDED and printed a filesystem report, and the mode assertion compared 600
against a line beginning `File: "..."`.

A fallback that only runs when the first command fails is not a fallback when
both platforms accept the flag and mean different things by it. Read the mode
through python, which means one thing on both.
@ruby-dlee
ruby-dlee force-pushed the fm/pi-account-home-projection branch from 2e2a174 to c58b314 Compare August 18, 2026 20:52
@ruby-dlee
ruby-dlee merged commit 78e163a into main Aug 18, 2026
13 checks passed
@ruby-dlee
ruby-dlee deleted the fm/pi-account-home-projection branch August 18, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant