From 46b3f70df4fc31696bf1998468289a1045c515ea Mon Sep 17 00:00:00 2001 From: Levi Figueira Date: Wed, 8 Jul 2026 01:06:47 +0100 Subject: [PATCH 1/4] docs: shape the gh-config-dir-profiles change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Successor to the parked PATH shim (PR #100): per-identity gh config profiles selected by GH_CONFIG_DIR — a path, not a secret — wired per-project through harness-native env (Claude Code settings.local.json first), with the github-account hook retreating to pure-read verification wherever a profile mapping exists and convergence surviving only as fallback. Maintainer-endorsed mechanism (cli/cli#12145); the native --account selector was closed unmerged upstream. Spikes gate the build: keyring isolation per config dir and credential-helper env inheritance. The shim's rejection is recorded in Cut with grounds — mechanism nature, not correctness — seeding the rejection-KB pattern. --- .../20260708-gh-config-dir-profiles/change.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/changes/20260708-gh-config-dir-profiles/change.md diff --git a/docs/changes/20260708-gh-config-dir-profiles/change.md b/docs/changes/20260708-gh-config-dir-profiles/change.md new file mode 100644 index 00000000..476bfdf9 --- /dev/null +++ b/docs/changes/20260708-gh-config-dir-profiles/change.md @@ -0,0 +1,161 @@ +--- +change: gh-config-dir-profiles +created: 2026-07-08 +branch: gh-config-dir-profiles +--- + +# GitHub Identity via Config-Dir Profiles + +## Problem + +`gh`'s identity is one global mutable pointer, and the two mechanisms built so far each carry a flaw the owner won't ship or live with. Tier-1 convergence (PR #99, merged) self-heals hooked sessions but *writes* the shared pointer on every mismatched call — read-only commands included — so concurrent multi-identity sessions collide more often than under the old pure-read guard. The tier-3 PATH shim (PR #100) survived adversarial review with zero blockers and was **rejected after implementation anyway**: a shim inherently requires PATH-ordering machinery in front of a well-known binary, and no consent UX changes that nature. Meanwhile the native per-command selector everyone actually wants (`GH_USER` / `--account`, cli/cli#12853) was closed unmerged upstream. + +What upstream *does* endorse (maintainer comment on cli/cli#12145): **`GH_CONFIG_DIR` profiles** — a config directory per identity, each with its own single-account `hosts.yml`, selected by an environment variable that is a *path, not a secret*. + +Constraints carried forward from the parked Change: agents keep typing bare `gh`; no credentials on disk beyond gh's existing keychain storage; a cloned repo must never install machinery on a machine; no PATH modification. + +## Hypothesis + +If each GitHub identity gets its own gh config profile and `GH_CONFIG_DIR` is wired per-project through each harness's native environment config, then every `gh` invocation in an agent session resolves the project's identity with **no shared mutable state contested between sessions** — no shim, no PATH edits, no tokens in env — and the enforcement hook can retreat from convergence (global writes) to pure-read verification, restoring the pre-#99 read-only posture without its deadlock. As a bonus the shim could never offer: session-scoped env is inherited by subprocesses, so `git push` over HTTPS — whose absolute-path `gh auth git-credential` helper structurally bypasses any PATH shim — resolves the same identity. + +## Scope + +**In** + +- **Profile management in the CLI**: create a per-identity profile directory (proposed home: `~/.config/gh-profiles//`), guide the one-time `gh auth login` into it, report profile health. Proposed surface: `loaf gh-profile create|status ` — final naming is a review decision (see fog). +- **Per-project env wiring, Claude Code first**: map the repo's `integrations.github.account` to the machine's profile dir by writing `GH_CONFIG_DIR` into `.claude/settings.local.json` env (machine-local, never committed — the repo names the *account*, the machine maps account→profile). Wiring is offered through the config-maintenance flow (`loaf config check --fix` or the profile command), with explicit consent per project. +- **Hook demotion to verification**: when the incoming Bash environment carries a `GH_CONFIG_DIR` whose profile resolves to the configured account, the `github-account` check passes as a pure read — no probe of the global pointer, no switch. Convergence remains the fallback when no profile mapping exists. The `gh auth` administration exemption stands. +- **Spikes as first implementation steps** (fog): keyring isolation mechanics per config dir, and empirical verification of the credential-helper inheritance claim. +- **Guidance**: hook-system.md, git-workflow, and loaf-reference configuration/troubleshooting reframed around profiles as the recommended opt-in tier; the residual matrix carried over from the parked Change and updated for the new mechanism. + +**Out** (deferred, not rejected) + +- Env wiring for the other harnesses (Codex, Cursor, OpenCode, Amp) — needs the per-harness env-config capability matrix; Claude Code proves the model first. Manual-export documentation covers them meanwhile. +- Bare-terminal automation (direnv/mise recipes) — documented as an optional pattern, not Loaf machinery; tier 1 remains the terminal net. +- Profile config synchronization (aliases, gh settings shared across profiles) — live with duplication until it hurts. +- GUI-launched applications — same env invisibility as every mechanism considered; documented residual. + +**Cut** (explicitly rejected) + +- **The PATH shim** — rejected after full implementation and clean review (PR #100, parked; branch `per-invocation-gh-identity` preserved as evidence). Grounds: mechanism nature, not correctness. First entry for the future rejection KB. +- **`GH_TOKEN` injection** (hook rewrite or otherwise) — puts token material in process environments when a non-secret selector achieves the same isolation; superseded by profiles. +- **Waiting on upstream `GH_USER`** — the PR was closed; if gh ever ships native per-command selection, profiles degrade gracefully into it (both are env-driven). +- **Auto-creating profiles or auto-wiring projects** — same consent doctrine as the shim: no repo-side knob, no heuristics, nothing exists on a machine that didn't ask. + +## Observable Workflow + +```text +# one-time per identity, per machine: +loaf gh-profile create levifig # creates ~/.config/gh-profiles/levifig, guides gh auth login into it + +# one-time per project, explicit consent: +loaf config check --fix # offers: wire GH_CONFIG_DIR for "levifig" into .claude/settings.local.json + +# from then on, in every agent session in this project: +gh pr list # runs as levifig via the profile — pointer never read, never written +git push # HTTPS credential helper inherits the same env (verified by spike) + +# concurrent session in a work repo wired to a different profile: zero shared state, nothing to race +# the github-account hook: pure-read verification when the profile env is present; convergence fallback otherwise +``` + +## Rabbit Holes and No-Gos + +- **No profile sync engine.** Duplicated gh aliases/config between profiles is accepted cost; the moment this Change starts syncing gh settings it has become a dotfile manager. +- **No env injection beyond the harness's native mechanism.** If a harness has no per-project env config, its coverage waits for the matrix follow-up — no wrapper scripts, no shell-hook installation, no PATH games through the back door. +- **No hook-side env mutation.** The hook verifies or falls back to convergence; it never sets `GH_CONFIG_DIR` itself (a check that mutates its own check condition is the circularity class the hook system just escaped). +- **No committed machine paths.** `GH_CONFIG_DIR` values live in machine-local files only (`settings.local.json`); anything committed names accounts, never paths. + +## Decisions + +Provenance: 1–6 accepted 2026-07-08 in the pivot conversation following the shim rejection (user: "Let's go"), building on the parked Change's carried-forward constraints; the upstream evidence is cli/cli#12145 (maintainer endorsement of config-dir profiles) and the closed cli/cli#12853. + +1. **Profiles are the opt-in tier; the shim is dead.** Per-identity `GH_CONFIG_DIR` profiles replace both the deferred tier-2 (hook rewrite) and the rejected tier-3 (shim). The tier vocabulary simplifies to: tier 1 convergence (default, zero footprint), tier 2 profiles (recommended opt-in, env-based). +2. **The selector is a path, never a secret.** `GH_CONFIG_DIR` in harness env config is committed-safe in nature but still kept machine-local, because it encodes a machine's filesystem layout and the account→profile mapping is the machine's business (trust boundary carried from the parked Change: repo names the account, machine picks the mechanism). +3. **Claude Code proves the model before the matrix.** One harness wired natively end-to-end beats five wired speculatively; the env-config capability matrix for the rest is its own follow-up. +4. **The hook retreats to reads wherever profiles reach.** Verification (env present + profile account matches) is a pure read; convergence — with its disclosed collision cost — survives only as the fallback tier. This restores the pre-#99 read-only posture without resurrecting its deadlock, because the exemption and convergence remain beneath it. +5. **Spike before build on the two load-bearing claims.** Keyring isolation per config dir and credential-helper env inheritance are the Hypothesis's foundations and are currently supported by a maintainer comment and reasoning, not evidence. Both spike first; findings write back here. +6. **The parked Change is evidence, not waste.** Its contract decisions (consent doctrine, trust boundary, no-cache reasoning), spike findings (gh ≥ 2.40.0, `GH_TOKEN` env-only precedence), residual matrix, and review record transfer; its rejection enters the Cut list with grounds, seeding the rejection-KB pattern the sweep will formalize. + +## Planning Contract + +### Profile layout and creation + +`~/.config/gh-profiles//` (respecting `XDG_CONFIG_HOME`); `loaf gh-profile create ` makes the directory and prints the login step (`GH_CONFIG_DIR= gh auth login`) rather than driving the interactive flow itself — gh owns its login UX. `status` reports: dir exists, `hosts.yml` present, exactly one account, account matches the profile name, keyring entry resolvable (`GH_CONFIG_DIR= gh auth token --user ` exits zero). `loaf doctor` gains the same checks for every profile referenced by any known project mapping. + +### Wiring mechanics (Claude Code) + +`.claude/settings.local.json` env gains `GH_CONFIG_DIR: `. Written only with per-project consent through the config-maintenance flow; removal is documented (delete the key). The spike verifies harness Bash sessions actually inherit settings env (expected: yes, it's the documented mechanism) and that subprocess inheritance carries to git's credential helper. + +### Hook verification semantics + +Order of evaluation in the `github-account` check: (1) `gh auth` administration → pass untouched (existing exemption); (2) `GH_CONFIG_DIR` present in the hook's view of the command env AND its profile's single account equals the configured account → pass, pure read; (3) `GH_CONFIG_DIR` present but mismatched → block with a wiring-repair message (never converge someone's explicit profile choice); (4) absent → convergence fallback as shipped in #99. Detail for the spike: what the hook process can actually see of the tool's env — the hook payload carries the command string, not the session env, so detection mechanics (reading the settings file vs. env probing) are an implementation fog item. + +### Residual matrix (carried and updated) + +| Path | Behavior | +|---|---| +| Agent session, project wired | profile env → correct identity; pointer unread, unwritten | +| Agent session, project not wired | tier-1 convergence (disclosed collision cost) | +| Bare terminal without env | tier-1 convergence where hooks run; global pointer otherwise (direnv recipe documented, optional) | +| `git push` over HTTPS, wired session | credential helper inherits `GH_CONFIG_DIR` → correct identity *(spike-verified before this row ships)* | +| GUI-launched apps | no env, no hooks — global pointer; unchanged, documented | +| Untrusted repo | names an *account*; the machine maps it to a profile only if the user wired it — strictly better than the shim's silent selection | +| `gh auth` administration | exempt, passes through (unchanged) | + +### Sequencing + +Spikes first (Decision 5); U-order below is likelihood-of-change. Independent of the parked branch. Docs edits re-do the tier framing on main directly (the parked branch's U4 framing never merged). + +## Implementation Units + +- **U1 — Spikes.** Keyring isolation per `GH_CONFIG_DIR` (two profiles, same machine, tokens resolve independently; document storage mechanics observed) and credential-helper inheritance (`git push` HTTPS from a wired env hits the profile's identity). Findings write back into this contract; a failed spike stops the Change at the cheapest point. +- **U2 — Wiring UX and consent.** The config-maintenance offer, `settings.local.json` write mechanics, removal path, per-project consent wording. +- **U3 — Profile commands.** `loaf gh-profile create|status`, doctor integration, the health checks. +- **U4 — Hook verification tier.** The evaluation order above, with tests per branch (wired-match pure-read pass asserts zero switch and zero status-probe of the global dir; wired-mismatch block; unwired convergence regression). +- **U5 — Guidance and close-out.** Tier reframing in hook docs and loaf-reference, residual matrix into troubleshooting, CLI-reference metadata for the new command, agent-help list, build and eval regeneration. + +## Verification Contract + +Executable: + +- **V1.** `loaf change check docs/changes/20260708-gh-config-dir-profiles` — zero violations; executable before implementation. +- **V2.** Spike evidence committed under `research/`: two-profile token isolation transcript and the credential-helper inheritance transcript, each reproducible by command. +- **V3.** Hook tests: wired-match passes with the global `hosts.yml` fixture unread and unwritten; wired-mismatch blocks with the repair message; unwired falls back to convergence (existing tests keep passing). +- **V4.** `loaf gh-profile status` and `loaf doctor` correctly classify: healthy profile, missing dir, multi-account profile, account-name mismatch, unresolvable token. Full suite and build green. + +Human review: + +- **H1.** The per-project wiring consent names the exact file and key it writes and how to undo it. +- **H2.** The residual matrix matches shipped behavior, including the spike-gated HTTPS row. +- **H3.** Dogfood on this machine: this repo wired to the `levifig` profile; the 2026-07-07 race rerun (adversary loop flipping the global pointer) with every probe resolving `levifig` — the parked Change's DoD, inherited. + +## Definition of Done + +- Spikes pass and their findings are folded in; U2–U5 shipped; V1–V4 green; H1–H3 confirmed. +- This repo runs wired as the first production use. +- The parked shim branch is referenced from the Cut list; no shim code merges. +- Deferred items (harness matrix, terminal automation) recorded for harvest. + +## Durable Outputs + +- ADR candidate after implementation: GitHub identity via config-dir profiles — the selector-is-a-path principle, the tier retreat from writes to reads, and the shim rejection rationale. +- `docs/knowledge/` note on the identity tiers, written from shipped behavior. +- The rejection record (shim) as seed material for the sweep's rejection KB. + +## Open Questions + +- [KU] Keyring isolation mechanics: how does gh's secure storage key entries across config dirs — per-dir, or per host+user shared? → U1 spike; determines whether profiles for the *same* account on one machine collide (maintainer comment implies per-profile logins). +- [KU] Credential-helper inheritance: does `gh auth git-credential` invoked by git actually receive the session's `GH_CONFIG_DIR`? → U1 spike; gates the HTTPS matrix row. +- [KU] Hook-side detection: can the check see the tool's effective env, or must it read `.claude/settings.local.json` (and equivalents) from disk? → owned by U4; recommended: read the settings file — deterministic, no env-passing dependency on harness hook payloads. +- [KU] Command naming: `loaf gh-profile` vs a subcommand under an existing noun — does a second gh-adjacent surface (after the parked `loaf shim`) warrant a shared `loaf gh …` namespace? → grilling question at review; recommended: `loaf gh-profile`, flat, renameable before release. +- [UK] Consent/offer wording for the per-project wiring → reaction artifact at U2: drafts in `research/`, pick at review. +- [UU] What else reads gh config location or assumes the default dir (gh extensions, other tools shelling to gh, IDE integrations) → blindspot pass during U1 alongside the spikes. + +## Source Inputs + +- The parked Change `docs/changes/20260707-per-invocation-gh-identity/` (branch `per-invocation-gh-identity`, PR #100 closed with the park rationale) — contract decisions, spike findings, residual matrix, and two external review rounds carried forward. +- cli/cli#12145 and its maintainer comment (2025-11-14) endorsing `GH_CONFIG_DIR` profiles; cli/cli#12853 (`--account`/`GH_ACCOUNT`) closed unmerged — the native path not taken by upstream. +- The 2026-07-08 pivot conversation: shim rejection grounds, the no-PATH constraint made explicit, profile mechanism evaluation, tier simplification — user-approved. +- PR #99 (merged) — tier-1 convergence with the `gh auth` exemption and the disclosed collision-frequency cost this Change lets wired projects escape. +- Journal decisions of 2026-07-07/08 under `decision(github-identity)` and `decision(hooks)`. From 681138e16ee0463cf81a6f21b4b71b302b0ade20 Mon Sep 17 00:00:00 2001 From: Levi Figueira Date: Wed, 8 Jul 2026 01:22:56 +0100 Subject: [PATCH 2/4] docs: fold the Discussion #188559 counterexample into the profiles change A community report of GH_CONFIG_DIR being honored by gh but not by git push credential resolution lands as a deliberately disconfirming Source Input: both U1 spikes sharpen (keychain by-host lookup is the suspected mechanism), the HTTPS matrix row's spike must reproduce the failure before deciding ships/ships-with-caveats/residual, and core scope is explicitly insulated from a failed spike. --- docs/changes/20260708-gh-config-dir-profiles/change.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changes/20260708-gh-config-dir-profiles/change.md b/docs/changes/20260708-gh-config-dir-profiles/change.md index 476bfdf9..42f91179 100644 --- a/docs/changes/20260708-gh-config-dir-profiles/change.md +++ b/docs/changes/20260708-gh-config-dir-profiles/change.md @@ -145,8 +145,8 @@ Human review: ## Open Questions -- [KU] Keyring isolation mechanics: how does gh's secure storage key entries across config dirs — per-dir, or per host+user shared? → U1 spike; determines whether profiles for the *same* account on one machine collide (maintainer comment implies per-profile logins). -- [KU] Credential-helper inheritance: does `gh auth git-credential` invoked by git actually receive the session's `GH_CONFIG_DIR`? → U1 spike; gates the HTTPS matrix row. +- [KU] Keyring isolation mechanics: how does gh's secure storage key entries across config dirs — per-dir, or per host+user shared in the OS keychain? → U1 spike; determines whether profiles for the *same* account collide, and whether git-credential lookups can resolve by-host regardless of profile context (the suspected mechanism behind the Discussion #188559 failure below). Maintainer comment implies per-profile logins; the keychain is machine-global, so verify, don't trust. +- [KU] Credential-helper inheritance: does `gh auth git-credential` invoked by git honor the session's `GH_CONFIG_DIR`? → U1 spike; gates the HTTPS matrix row — **with a documented counterexample to reproduce first**: GitHub Community Discussion #188559 reports exactly this failing (`gh auth status` shows the profile account, `git push` authenticates as the previously logged-in one). The spike must reproduce that report, identify the mechanism (keychain by-host lookup vs. a coexisting `osxkeychain` helper vs. env non-propagation), and only then decide whether the HTTPS row ships, ships-with-caveats, or moves to the residual list. Core scope survives a failed spike — agent `gh` commands and SSH remotes don't touch this path. - [KU] Hook-side detection: can the check see the tool's effective env, or must it read `.claude/settings.local.json` (and equivalents) from disk? → owned by U4; recommended: read the settings file — deterministic, no env-passing dependency on harness hook payloads. - [KU] Command naming: `loaf gh-profile` vs a subcommand under an existing noun — does a second gh-adjacent surface (after the parked `loaf shim`) warrant a shared `loaf gh …` namespace? → grilling question at review; recommended: `loaf gh-profile`, flat, renameable before release. - [UK] Consent/offer wording for the per-project wiring → reaction artifact at U2: drafts in `research/`, pick at review. @@ -156,6 +156,7 @@ Human review: - The parked Change `docs/changes/20260707-per-invocation-gh-identity/` (branch `per-invocation-gh-identity`, PR #100 closed with the park rationale) — contract decisions, spike findings, residual matrix, and two external review rounds carried forward. - cli/cli#12145 and its maintainer comment (2025-11-14) endorsing `GH_CONFIG_DIR` profiles; cli/cli#12853 (`--account`/`GH_ACCOUNT`) closed unmerged — the native path not taken by upstream. +- GitHub Community Discussion #188559 (via devactivity.com write-up, evaluated 2026-07-08) — a **disconfirming input** for the HTTPS credential-helper row: `GH_CONFIG_DIR` respected by gh itself but reportedly not by `git push` credential resolution. Sharpened both U1 spikes; per the pilot's survivorship-bias practice, deliberately kept alongside the confirming maintainer comment. - The 2026-07-08 pivot conversation: shim rejection grounds, the no-PATH constraint made explicit, profile mechanism evaluation, tier simplification — user-approved. - PR #99 (merged) — tier-1 convergence with the `gh auth` exemption and the disclosed collision-frequency cost this Change lets wired projects escape. - Journal decisions of 2026-07-07/08 under `decision(github-identity)` and `decision(hooks)`. From a00da4b4d0626db5c278d19462bd160080a62525 Mon Sep 17 00:00:00 2001 From: Levi Figueira Date: Wed, 8 Jul 2026 01:29:44 +0100 Subject: [PATCH 3/4] docs: fold the GH_TOKEN and conditional-helper approaches into the profiles change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more #12145 inputs adjudicated: the maintainer's session-wide GH_TOKEN export is concurrency-safe (env is per-process, zero disk mutation — verified in the parked change's spike) but rejected as the wired mechanism, since it puts a live secret in every subprocess env and cannot be statically wired into harness config; kept as the zero-setup terminal recipe. The field-proven conditional credential helper (per-tree or repo-local git config resolving a named token inline) becomes the likely shipped answer for the HTTPS matrix row — the one surface gh's own mechanisms miss — and a U2 wiring candidate. The U1 spike now adjudicates three candidate mechanisms for HTTPS. --- docs/changes/20260708-gh-config-dir-profiles/change.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/changes/20260708-gh-config-dir-profiles/change.md b/docs/changes/20260708-gh-config-dir-profiles/change.md index 42f91179..fb1bc4dd 100644 --- a/docs/changes/20260708-gh-config-dir-profiles/change.md +++ b/docs/changes/20260708-gh-config-dir-profiles/change.md @@ -38,7 +38,7 @@ If each GitHub identity gets its own gh config profile and `GH_CONFIG_DIR` is wi **Cut** (explicitly rejected) - **The PATH shim** — rejected after full implementation and clean review (PR #100, parked; branch `per-invocation-gh-identity` preserved as evidence). Grounds: mechanism nature, not correctness. First entry for the future rejection KB. -- **`GH_TOKEN` injection** (hook rewrite or otherwise) — puts token material in process environments when a non-secret selector achieves the same isolation; superseded by profiles. +- **`GH_TOKEN` injection as the wired mechanism** (hook rewrite, or the maintainer's session-wide `export GH_TOKEN=$(gh auth token --user …)` from the second #12145 comment) — concurrency-safe (env is per-process; verified zero disk mutation), but it makes the selector a live secret inherited by every subprocess of the session, and it cannot be statically wired into harness env config (static strings can't run command substitution — the computing layer required is the hook-rewrite tier already cut). Documented instead as the zero-setup *terminal* recipe, and spiked as a possible HTTPS workaround (fog). - **Waiting on upstream `GH_USER`** — the PR was closed; if gh ever ships native per-command selection, profiles degrade gracefully into it (both are env-driven). - **Auto-creating profiles or auto-wiring projects** — same consent doctrine as the shim: no repo-side knob, no heuristics, nothing exists on a machine that didn't ask. @@ -98,7 +98,7 @@ Order of evaluation in the `github-account` check: (1) `gh auth` administration | Agent session, project wired | profile env → correct identity; pointer unread, unwritten | | Agent session, project not wired | tier-1 convergence (disclosed collision cost) | | Bare terminal without env | tier-1 convergence where hooks run; global pointer otherwise (direnv recipe documented, optional) | -| `git push` over HTTPS, wired session | credential helper inherits `GH_CONFIG_DIR` → correct identity *(spike-verified before this row ships)* | +| `git push` over HTTPS, wired session | three candidate mechanisms, spike-adjudicated before this row ships: `GH_CONFIG_DIR` inheritance (counterexample on record), `GH_TOKEN` short-circuit, and the field-proven **conditional credential helper** (`helper = !GH_TOKEN=$(gh auth token --user ) gh auth git-credential`, third #12145 comment) — per-invocation, no stored secret, wireable repo-locally in `.git/config`; likely the shipped answer | | GUI-launched apps | no env, no hooks — global pointer; unchanged, documented | | Untrusted repo | names an *account*; the machine maps it to a profile only if the user wired it — strictly better than the shim's silent selection | | `gh auth` administration | exempt, passes through (unchanged) | @@ -146,7 +146,7 @@ Human review: ## Open Questions - [KU] Keyring isolation mechanics: how does gh's secure storage key entries across config dirs — per-dir, or per host+user shared in the OS keychain? → U1 spike; determines whether profiles for the *same* account collide, and whether git-credential lookups can resolve by-host regardless of profile context (the suspected mechanism behind the Discussion #188559 failure below). Maintainer comment implies per-profile logins; the keychain is machine-global, so verify, don't trust. -- [KU] Credential-helper inheritance: does `gh auth git-credential` invoked by git honor the session's `GH_CONFIG_DIR`? → U1 spike; gates the HTTPS matrix row — **with a documented counterexample to reproduce first**: GitHub Community Discussion #188559 reports exactly this failing (`gh auth status` shows the profile account, `git push` authenticates as the previously logged-in one). The spike must reproduce that report, identify the mechanism (keychain by-host lookup vs. a coexisting `osxkeychain` helper vs. env non-propagation), and only then decide whether the HTTPS row ships, ships-with-caveats, or moves to the residual list. Core scope survives a failed spike — agent `gh` commands and SSH remotes don't touch this path. +- [KU] Credential-helper inheritance: does `gh auth git-credential` invoked by git honor the session's `GH_CONFIG_DIR`? → U1 spike; gates the HTTPS matrix row — **with a documented counterexample to reproduce first**: GitHub Community Discussion #188559 reports exactly this failing (`gh auth status` shows the profile account, `git push` authenticates as the previously logged-in one). The spike must reproduce that report, identify the mechanism (keychain by-host lookup vs. a coexisting `osxkeychain` helper vs. env non-propagation), and only then decide whether the HTTPS row ships, ships-with-caveats, or moves to the residual list. The same harness also tests **`GH_TOKEN` against the helper** (maintainer's session-export suggestion, second #12145 comment): it short-circuits token lookup entirely, so it plausibly survives where the profile's keychain resolution fails — if so, it becomes the documented HTTPS workaround even though it stays rejected as Loaf's wired mechanism (secret-in-session-env; not statically wireable for agents). Core scope survives a failed spike — agent `gh` commands and SSH remotes don't touch this path. - [KU] Hook-side detection: can the check see the tool's effective env, or must it read `.claude/settings.local.json` (and equivalents) from disk? → owned by U4; recommended: read the settings file — deterministic, no env-passing dependency on harness hook payloads. - [KU] Command naming: `loaf gh-profile` vs a subcommand under an existing noun — does a second gh-adjacent surface (after the parked `loaf shim`) warrant a shared `loaf gh …` namespace? → grilling question at review; recommended: `loaf gh-profile`, flat, renameable before release. - [UK] Consent/offer wording for the per-project wiring → reaction artifact at U2: drafts in `research/`, pick at review. @@ -157,6 +157,8 @@ Human review: - The parked Change `docs/changes/20260707-per-invocation-gh-identity/` (branch `per-invocation-gh-identity`, PR #100 closed with the park rationale) — contract decisions, spike findings, residual matrix, and two external review rounds carried forward. - cli/cli#12145 and its maintainer comment (2025-11-14) endorsing `GH_CONFIG_DIR` profiles; cli/cli#12853 (`--account`/`GH_ACCOUNT`) closed unmerged — the native path not taken by upstream. - GitHub Community Discussion #188559 (via devactivity.com write-up, evaluated 2026-07-08) — a **disconfirming input** for the HTTPS credential-helper row: `GH_CONFIG_DIR` respected by gh itself but reportedly not by `git push` credential resolution. Sharpened both U1 spikes; per the pilot's survivorship-bias practice, deliberately kept alongside the confirming maintainer comment. +- The second #12145 maintainer comment (session-wide `export GH_TOKEN=$(gh auth token --user …)`) — concurrency-safe and endorsed, rejected as the *wired* mechanism (secret in session env; not statically wireable for agents); kept as the zero-setup terminal recipe and an HTTPS-spike candidate. +- The third #12145 comment (simenbrekken, 2026-03-30) — the **conditional credential helper**: per-tree/per-repo git config overriding the helper with inline named-token resolution. Field-proven for git-HTTPS identity (the surface `gh`'s own mechanisms miss); complements profiles rather than competing; the likely shipped answer for the HTTPS matrix row and a U2 wiring candidate (repo-local `.git/config`). - The 2026-07-08 pivot conversation: shim rejection grounds, the no-PATH constraint made explicit, profile mechanism evaluation, tier simplification — user-approved. - PR #99 (merged) — tier-1 convergence with the `gh auth` exemption and the disclosed collision-frequency cost this Change lets wired projects escape. - Journal decisions of 2026-07-07/08 under `decision(github-identity)` and `decision(hooks)`. From 0ec988fcb25682c864a972384f660e166a9407b2 Mon Sep 17 00:00:00 2001 From: Levi Figueira Date: Wed, 8 Jul 2026 01:40:16 +0100 Subject: [PATCH 4/4] docs: fold U1 spike verdicts into the profiles change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both gate spikes pass with corrections. Keyring isolation is real at the pointer layer only: profiles scope the active account, but credentials live once machine-wide in the OS keychain, and --user/GH_TOKEN bypass profile scoping entirely — profile health checks now probe the active account without --user. Discussion #188559 did not reproduce here but its mechanism is real (config-merge helper ordering), so GH_CONFIG_DIR inheritance is documented as unreliable and the repo-local conditional credential helper ships as the HTTPS mechanism, spike-verified to pin identity against an adversarial pointer. New residual: a stale osxkeychain internet-password for github.com found on this machine — status/doctor will warn on the class. Transcripts in research/. --- .../20260708-gh-config-dir-profiles/change.md | 11 +- .../research/spike-https-credential-helper.md | 141 ++++++++++++++++++ .../research/spike-keyring-isolation.md | 128 ++++++++++++++++ 3 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 docs/changes/20260708-gh-config-dir-profiles/research/spike-https-credential-helper.md create mode 100644 docs/changes/20260708-gh-config-dir-profiles/research/spike-keyring-isolation.md diff --git a/docs/changes/20260708-gh-config-dir-profiles/change.md b/docs/changes/20260708-gh-config-dir-profiles/change.md index fb1bc4dd..b35a5318 100644 --- a/docs/changes/20260708-gh-config-dir-profiles/change.md +++ b/docs/changes/20260708-gh-config-dir-profiles/change.md @@ -81,11 +81,11 @@ Provenance: 1–6 accepted 2026-07-08 in the pivot conversation following the sh ### Profile layout and creation -`~/.config/gh-profiles//` (respecting `XDG_CONFIG_HOME`); `loaf gh-profile create ` makes the directory and prints the login step (`GH_CONFIG_DIR= gh auth login`) rather than driving the interactive flow itself — gh owns its login UX. `status` reports: dir exists, `hosts.yml` present, exactly one account, account matches the profile name, keyring entry resolvable (`GH_CONFIG_DIR= gh auth token --user ` exits zero). `loaf doctor` gains the same checks for every profile referenced by any known project mapping. +`~/.config/gh-profiles//` (respecting `XDG_CONFIG_HOME`); `loaf gh-profile create ` makes the directory and prints the login step (`GH_CONFIG_DIR= gh auth login`) rather than driving the interactive flow itself — gh owns its login UX. `status` reports: dir exists, `hosts.yml` present, exactly one account, account matches the profile name, and the *active-account* token resolves (`GH_CONFIG_DIR= gh auth token` with **no `--user` flag** — the spike proved `--user` bypasses profile scoping and would vacuously pass against any machine-known account). Status also warns when a stale osxkeychain internet-password for the host coexists with gh's entries (the spike's footgun finding). `loaf doctor` gains the same checks for every profile referenced by any known project mapping. ### Wiring mechanics (Claude Code) -`.claude/settings.local.json` env gains `GH_CONFIG_DIR: `. Written only with per-project consent through the config-maintenance flow; removal is documented (delete the key). The spike verifies harness Bash sessions actually inherit settings env (expected: yes, it's the documented mechanism) and that subprocess inheritance carries to git's credential helper. +`.claude/settings.local.json` env gains `GH_CONFIG_DIR: `. Written only with per-project consent through the config-maintenance flow; removal is documented (delete the key). Wiring additionally offers the **repo-local conditional credential helper** (`.git/config`: helper reset + `!GH_TOKEN=$(gh auth token --user ) gh auth git-credential`) for projects with HTTPS remotes — the spike-adjudicated HTTPS mechanism; skipped for SSH-remote projects. One remaining inheritance check rides U2: that harness Bash sessions actually receive settings env (the documented mechanism; verify once during wiring implementation). ### Hook verification semantics @@ -98,7 +98,8 @@ Order of evaluation in the `github-account` check: (1) `gh auth` administration | Agent session, project wired | profile env → correct identity; pointer unread, unwritten | | Agent session, project not wired | tier-1 convergence (disclosed collision cost) | | Bare terminal without env | tier-1 convergence where hooks run; global pointer otherwise (direnv recipe documented, optional) | -| `git push` over HTTPS, wired session | three candidate mechanisms, spike-adjudicated before this row ships: `GH_CONFIG_DIR` inheritance (counterexample on record), `GH_TOKEN` short-circuit, and the field-proven **conditional credential helper** (`helper = !GH_TOKEN=$(gh auth token --user ) gh auth git-credential`, third #12145 comment) — per-invocation, no stored secret, wireable repo-locally in `.git/config`; likely the shipped answer | +| `git push` over HTTPS, wired session | **ships via the conditional credential helper** (repo-local `.git/config`: `helper =` reset, then `helper = !GH_TOKEN=$(gh auth token --user ) gh auth git-credential`) — spike-verified to pin identity with zero ambient env and the global pointer on the other account. `GH_CONFIG_DIR` inheritance alone is unreliable (works only under favorable helper ordering — the #188559 failure mechanism); `GH_TOKEN` export stays a terminal recipe | +| Stale OS-keychain credential for github.com | a pre-existing osxkeychain internet-password (work account) coexists with gh's entries on this machine; any future helper-ordering change could resurrect it as the winning HTTPS identity — flagged by the spike, worth deleting; `loaf gh-profile status`/doctor should warn when one exists | | GUI-launched apps | no env, no hooks — global pointer; unchanged, documented | | Untrusted repo | names an *account*; the machine maps it to a profile only if the user wired it — strictly better than the shim's silent selection | | `gh auth` administration | exempt, passes through (unchanged) | @@ -145,8 +146,8 @@ Human review: ## Open Questions -- [KU] Keyring isolation mechanics: how does gh's secure storage key entries across config dirs — per-dir, or per host+user shared in the OS keychain? → U1 spike; determines whether profiles for the *same* account collide, and whether git-credential lookups can resolve by-host regardless of profile context (the suspected mechanism behind the Discussion #188559 failure below). Maintainer comment implies per-profile logins; the keychain is machine-global, so verify, don't trust. -- [KU] Credential-helper inheritance: does `gh auth git-credential` invoked by git honor the session's `GH_CONFIG_DIR`? → U1 spike; gates the HTTPS matrix row — **with a documented counterexample to reproduce first**: GitHub Community Discussion #188559 reports exactly this failing (`gh auth status` shows the profile account, `git push` authenticates as the previously logged-in one). The spike must reproduce that report, identify the mechanism (keychain by-host lookup vs. a coexisting `osxkeychain` helper vs. env non-propagation), and only then decide whether the HTTPS row ships, ships-with-caveats, or moves to the residual list. The same harness also tests **`GH_TOKEN` against the helper** (maintainer's session-export suggestion, second #12145 comment): it short-circuits token lookup entirely, so it plausibly survives where the profile's keychain resolution fails — if so, it becomes the documented HTTPS workaround even though it stays rejected as Loaf's wired mechanism (secret-in-session-env; not statically wireable for agents). Core scope survives a failed spike — agent `gh` commands and SSH remotes don't touch this path. +- ~~[KU] Keyring isolation mechanics~~ → resolved by U1 spike (`research/spike-keyring-isolation.md`): **isolation is real at the pointer layer only**. Each profile's `hosts.yml` is a pointer naming its active account and holds no secret; credentials live once machine-wide in the OS keychain keyed `(gh:, )` — three temp-profile logins created zero new keychain entries. Commands reading the *active* pointer (`gh api`, `gh auth status`, `gh auth git-credential`) are correctly profile-scoped; `--user`-flagged commands and `GH_TOKEN` bypass profiles entirely and can pull any machine-known account's token from any profile. Consequences folded in: profile health checks must probe the active account, never `--user` (Planning Contract updated); the Hypothesis's no-shared-state claim holds for what raced (pointer writes) — the credential *store* is shared, read-mostly, and uncontested. +- ~~[KU] Credential-helper inheritance~~ → resolved by U1 spike (`research/spike-https-credential-helper.md`): **#188559 did not reproduce here, but its mechanism is real** — `GH_CONFIG_DIR` inheritance works on this machine only by config-merge ordering luck (gh's github.com-specific helper happens to layer after Homebrew's system-level `osxkeychain`); a differently-ordered machine plausibly fails exactly as reported. Adjudication: **the conditional credential helper ships** (repo-local `.git/config`, verified pinning identity with zero ambient env and the pointer on the other account); `GH_TOKEN` short-circuit works unconditionally but stays a terminal recipe (secret-in-env); `GH_CONFIG_DIR`-alone is documented as unreliable, never sufficient. Bonus finding: a stale osxkeychain internet-password for github.com (under the work account) sits on this machine as a standing footgun — added to the residual matrix. - [KU] Hook-side detection: can the check see the tool's effective env, or must it read `.claude/settings.local.json` (and equivalents) from disk? → owned by U4; recommended: read the settings file — deterministic, no env-passing dependency on harness hook payloads. - [KU] Command naming: `loaf gh-profile` vs a subcommand under an existing noun — does a second gh-adjacent surface (after the parked `loaf shim`) warrant a shared `loaf gh …` namespace? → grilling question at review; recommended: `loaf gh-profile`, flat, renameable before release. - [UK] Consent/offer wording for the per-project wiring → reaction artifact at U2: drafts in `research/`, pick at review. diff --git a/docs/changes/20260708-gh-config-dir-profiles/research/spike-https-credential-helper.md b/docs/changes/20260708-gh-config-dir-profiles/research/spike-https-credential-helper.md new file mode 100644 index 00000000..ff00eb09 --- /dev/null +++ b/docs/changes/20260708-gh-config-dir-profiles/research/spike-https-credential-helper.md @@ -0,0 +1,141 @@ +# Spike: HTTPS Credential-Helper Adjudication + +**Date:** 2026-07-08 +**Machine:** macOS, `gh` 2.96.0, Homebrew git, login.keychain-db +**Open question addressed:** "Does `gh auth git-credential` invoked by git honor the session's `GH_CONFIG_DIR`?" — with the documented counterexample, GitHub Community Discussion #188559, reproduced first. + +## Baseline: what helper is actually configured + +```bash +$ git config --show-origin --get-all credential.helper +file:/opt/homebrew/etc/gitconfig osxkeychain + +$ git config --show-origin --get-all credential.https://github.com.helper +file:/Users/levifig/.config/git/config +file:/Users/levifig/.config/git/config !/opt/homebrew/bin/gh auth git-credential +``` + +Two helpers are in play, not one: Homebrew's system-level `git` config installs `osxkeychain` as a general `credential.helper` (applies to all hosts), and the user's `~/.config/git/config` layers a github.com-specific `!gh auth git-credential` helper on top. Git runs applicable helpers in config-merge order (system → global → local) and a later helper's fields override an earlier helper's for the same operation — so on this machine `gh`'s helper is expected to win over `osxkeychain` for github.com URLs, *if* it returns a value. This dual-helper setup is exactly the shape of mechanism the open question flagged ("a coexisting `osxkeychain` helper"). + +**Blindspot finding:** `osxkeychain` already has a stored `github.com` credential, independent of gh's own `gh:github.com` keychain items: + +```bash +$ security dump-keychain | grep -A15 'srvr.*github.com' # class "inet" (osxkeychain), metadata only +class=inet acct="levifigueira" created=20251208131619 +class=inet acct="git" created=20260111022955 +``` + +This is a real, pre-existing artifact on this machine (not created by this spike) — evidence that something (an earlier `git push`, VS Code, or another tool) once stored HTTPS credentials via `osxkeychain` directly, bypassing gh. It is stale relative to the current default profile (default active account is `levifig`; this cached entry says `levifigueira`), which makes it a perfect natural disambiguator for the tests below. + +## Baseline `git credential fill` (no overrides) + +```bash +$ gh auth status | grep -E 'Active account: true' -B3 +github.com + ✓ Logged in to github.com account levifig (keyring) + - Active account: true + +$ printf 'protocol=https\nhost=github.com\n\n' | git credential fill +protocol=https +host=github.com +username=levifig +``` + +Default profile active account is `levifig`; `git credential fill` correctly returns `levifig` — **not** the stale `osxkeychain` entry (`levifigueira`). This already shows `gh`'s helper wins over `osxkeychain` in the default case. + +## (a) `GH_CONFIG_DIR` alone — attempting to reproduce Discussion #188559 + +```bash +$ GH_CONFIG_DIR=$PROFILE_B git auth status # (via gh) reports levifigueira active, correctly + +$ printf 'protocol=https\nhost=github.com\n\n' | GH_CONFIG_DIR="$PROFILE_B" git credential fill +protocol=https +host=github.com +username=levifigueira +``` + +This alone is ambiguous — `osxkeychain`'s stale entry also happens to say `levifigueira`, so a naive read could mistake coincidence for causation. **Disambiguating test**, switching to the *other* profile while the stale `osxkeychain` entry still says `levifigueira`: + +```bash +$ printf 'protocol=https\nhost=github.com\n\n' | GH_CONFIG_DIR="$PROFILE_A" git credential fill +protocol=https +host=github.com +username=levifig +``` + +`GH_CONFIG_DIR=$PROFILE_A` (account `levifig`) correctly returns `levifig`, despite `osxkeychain`'s cached entry being `levifigueira`. Since the stale entry is constant across both tests but the result tracks `GH_CONFIG_DIR` in both directions, the result is *not* being driven by `osxkeychain`'s cache — it is being driven by `gh auth git-credential`, confirmed directly: + +```bash +$ printf 'protocol=https\nhost=github.com\n\n' | GH_CONFIG_DIR="$PROFILE_B" gh auth git-credential get +protocol=https +host=github.com +username=levifigueira +``` + +**Discussion #188559 does NOT reproduce on this machine/git/gh version combination.** `GH_CONFIG_DIR` alone is sufficient for `git credential fill` (and therefore `git push` over HTTPS) to resolve the profile's identity, because the configured helper chain has `gh auth git-credential` positioned to override the general `osxkeychain` fallback for github.com URLs specifically. + +**Why the disconfirming report likely differs elsewhere:** the mechanism that makes this work here is fragile precisely because it depends on config *ordering* between two independently-installed helpers (`osxkeychain` from a Homebrew system gitconfig, `gh`'s from a user gitconfig) — not on any structural guarantee. A machine where `osxkeychain` is configured *after* the `gh` helper (or where only `osxkeychain` has ever cached github.com and gh's helper errors/is absent from PATH in the invoking process) would plausibly reproduce #188559. This is an env/config-topology-dependent outcome, not a settled cross-machine guarantee — worth stating as a caveat in the shipped row rather than a blanket "works." + +## (b) `GH_TOKEN` short-circuit + +```bash +$ TOKEN_B="$(gh auth token --user levifigueira)" +$ printf 'protocol=https\nhost=github.com\n\n' | GH_TOKEN="$TOKEN_B" git credential fill +protocol=https +host=github.com +username=x-access-token +``` + +`GH_TOKEN` does short-circuit the helper — but the returned `username` field is the generic literal `x-access-token`, not the account name. Verified this is nonetheless the correct identity by checking what the token itself resolves to via the API (independent of the credential-fill flow): + +```bash +$ GH_TOKEN="$TOKEN_B" gh api user --jq .login +levifigueira +``` + +**Confirmed:** `GH_TOKEN` correctly and unconditionally short-circuits to whichever account the token belongs to — it does not depend on `GH_CONFIG_DIR`, the active pointer, or `osxkeychain` ordering at all. This matches the parked change's carried-forward finding (`GH_TOKEN` env-only precedence, gh ≥ 2.40.0) and extends it to the git-credential path specifically. + +## (c) Conditional credential helper (repo-local, no ambient override) + +Set up in a throwaway temp repo, with the ambient environment carrying **neither** `GH_CONFIG_DIR` nor `GH_TOKEN`, and the machine's default active account still `levifig`: + +```bash +$ git init /tmp/.../temp-repo-conditional-helper && cd $_ +$ git config credential.https://github.com.helper "" # reset — clear inherited helper list +$ git config --add credential.https://github.com.helper \ + '!GH_TOKEN=$(gh auth token --user levifigueira) gh auth git-credential' + +$ git config --local --get-all credential.https://github.com.helper +!GH_TOKEN=$(gh auth token --user levifigueira) gh auth git-credential + +$ env | grep '^GH_' +(none set) + +$ printf 'protocol=https\nhost=github.com\n\n' | git credential fill +protocol=https +host=github.com +username=x-access-token +``` + +Per the `GH_TOKEN` finding above, `username=x-access-token` here means the helper's `GH_TOKEN=$(gh auth token --user levifigueira)` substitution ran and short-circuited correctly — the identity is `levifigueira`'s token regardless of the ambient environment or the machine's default pointer. **Confirmed: the conditional helper pins identity per-repo, independent of session env, independent of the global pointer, independent of `GH_CONFIG_DIR`.** + +## Blindspot pass (bounded, not exhaustive) + +- `gh extension list` → one extension installed: `gh copilot` (github/gh-copilot). Doesn't touch config-dir/credential resolution. +- No `GH_CONFIG_DIR` references found in `~/.zshrc`, `~/.zprofile`, `~/.bashrc`, `~/.bash_profile`. +- No other gh-adjacent CLIs found on PATH (`hub`, `glab`, `act`, `gh-dash` — none installed). +- The `osxkeychain` stale `github.com` credential (found above) is itself a blindspot instance worth carrying into the residual matrix: *some* tool, at some point, wrote a github.com credential via the generic `osxkeychain` git helper outside gh's control. Any future change to helper ordering (e.g., a different git install, a global `credential.helper` reset) could resurrect that stale entry as the winning identity. + +## Verdicts + +| Candidate | Result | Notes | +|---|---|---| +| **(a) `GH_CONFIG_DIR` inheritance alone** | **Works on this machine** — Discussion #188559 does not reproduce here | Depends on helper *ordering* between `osxkeychain` (system) and `gh`'s helper (user config) being gh-last-wins; not a structural guarantee, fragile across machines/git installs | +| **(b) `GH_TOKEN` short-circuit** | **Works, unconditionally** | Correct identity confirmed via API; username field is a generic `x-access-token` placeholder, not the account name — cosmetic only | +| **(c) Conditional credential helper** | **Works, unconditionally, repo-local, no ambient env dependency** | Confirmed identity pins to the wired account regardless of `GH_CONFIG_DIR`, `GH_TOKEN`, or the global pointer | + +**HTTPS row verdict:** ship the **conditional credential helper (c)** as the wired mechanism, per the contract's existing lean. It is the only one of the three that does not depend on machine-specific helper-ordering luck (unlike (a)) and does not require a live secret in session env inherited by every subprocess (unlike (b), which the contract already rejected as the wired mechanism for that reason). `GH_CONFIG_DIR` inheritance (a) is real and worth documenting as a **bonus effect** when it happens to work (as it does here), but should **not** be the row's sole claimed mechanism — the ordering dependency identified above means it can silently fail on a different machine, exactly matching #188559's report. The row should read: ships via the conditional helper; `GH_CONFIG_DIR` alone is insufficient/unreliable as a guarantee even though it happens to work on this reference machine. + +## Cleanup + +The temp repo (`temp-repo-conditional-helper`) and all temp profile directories from the companion keyring spike were `rm -rf`'d at the end of testing (shared cleanup pass, see the keyring-isolation transcript). No token values were printed at any point; only exit codes, account-name outputs (`gh api user --jq .login`), and the `username=` line from `git credential fill` were captured. No git push was performed. No repo mutation reached GitHub — the only live network calls were read-only `gh api user` and `gh auth status`/`git credential fill` (the latter is local-only; it does not contact GitHub). The default gh profile and global git config were never written to by this spike (only repo-local config in the disposable temp repo, and env-scoped variables in temp shell invocations). diff --git a/docs/changes/20260708-gh-config-dir-profiles/research/spike-keyring-isolation.md b/docs/changes/20260708-gh-config-dir-profiles/research/spike-keyring-isolation.md new file mode 100644 index 00000000..457e26a4 --- /dev/null +++ b/docs/changes/20260708-gh-config-dir-profiles/research/spike-keyring-isolation.md @@ -0,0 +1,128 @@ +# Spike: Keyring Isolation Across `GH_CONFIG_DIR` Profiles + +**Date:** 2026-07-08 +**Machine:** macOS, `gh` 2.96.0, Homebrew git, login.keychain-db +**Open question addressed:** "Keyring isolation mechanics: how does gh's secure storage key entries across config dirs — per-dir, or per host+user shared in the OS keychain?" + +## Setup + +Two accounts already authenticated in the default profile (`~/.config/gh`, untouched throughout): `levifig` (active) and `levifigueira` (inactive). Three temp profiles created under a session-scratchpad directory (deleted at the end of this spike), each authenticated via `gh auth login --with-token` fed from `gh auth token --user ` against the *default* profile — no interactive/browser login used anywhere. + +```bash +PROFILE_A=/profile-A-levifig # levifig +PROFILE_B=/profile-B-levifigueira # levifigueira +PROFILE_C=/profile-C-levifig-second # levifig again, SECOND profile for the same account + +TOKEN_A="$(gh auth token --user levifig)" +TOKEN_B="$(gh auth token --user levifigueira)" +printf '%s' "$TOKEN_A" | GH_CONFIG_DIR="$PROFILE_A" gh auth login --hostname github.com --with-token +printf '%s' "$TOKEN_B" | GH_CONFIG_DIR="$PROFILE_B" gh auth login --hostname github.com --with-token +printf '%s' "$TOKEN_A" | GH_CONFIG_DIR="$PROFILE_C" gh auth login --hostname github.com --with-token +``` + +All three logins exited 0. + +## (a) Does each profile resolve its own account? + +```bash +$ GH_CONFIG_DIR="$PROFILE_A" gh api user --jq .login +levifig +$ GH_CONFIG_DIR="$PROFILE_B" gh api user --jq .login +levifigueira +$ GH_CONFIG_DIR="$PROFILE_C" gh api user --jq .login +levifig +``` + +`gh auth status` under each `GH_CONFIG_DIR` reports the correct account as `Active account: true`. **Yes — for gh's own commands (`api`, `status`, and by extension `pr`/`issue`/etc.), the config dir's `hosts.yml` "active user" pointer correctly scopes identity.** + +## (b) Where do credentials actually live? + +`hosts.yml` in each temp profile (oauth_token pattern redacted defensively; none was present — storage is keyring, not file): + +``` +# PROFILE_A/hosts.yml +github.com: + users: + levifig: + user: levifig + +# PROFILE_B/hosts.yml +github.com: + users: + levifigueira: + user: levifigueira + +# PROFILE_C/hosts.yml +github.com: + users: + levifig: + user: levifig +``` + +Each `hosts.yml` is a **pointer file**: it names which account is "active" for that config dir. It contains no secret material — confirmed via `gh auth status` reporting `(keyring)` next to each login, not `(file)`. + +**Control test — does `--user` actually consult the keyring, or does it silently ignore the flag?** + +```bash +$ GH_CONFIG_DIR="$PROFILE_A" gh auth token --user this-account-does-not-exist-xyz +no oauth token found for github.com account this-account-does-not-exist-xyz +exit: 1 +``` + +Confirms `--user` does a real keyed lookup (fails cleanly for an unknown account) rather than ignoring the flag. + +**The critical test — does Profile A (only ever authenticated as `levifig`) leak `levifigueira`'s token?** + +```bash +$ GH_CONFIG_DIR="$PROFILE_A" gh auth token --user levifigueira >/dev/null 2>&1; echo $? +0 +``` + +Exit 0. Profile A's `hosts.yml` lists **only** `levifig` under `users:` — `levifigueira` is not present in that file — yet `gh auth token --user levifigueira` succeeds from inside Profile A's config dir. Byte-for-byte confirmation: + +```bash +$ [ "$(gh auth token --user levifigueira)" = "$(GH_CONFIG_DIR="$PROFILE_A" gh auth token --user levifigueira)" ] && echo "IDENTICAL token value" +IDENTICAL token value +``` + +**Mechanism:** `gh auth token --user ` does not consult the config dir's `hosts.yml` at all — it queries the OS keychain directly by `(service, account)`. The keychain is machine-global and keyed by **host + username**, not by config directory. + +**Keychain inspection (metadata only — service and account names, never secret values):** + +Baseline, before any temp-profile activity, `login.keychain-db` already had 3 entries with `svce = "gh:github.com"`: + +``` +acct="levifigueira" +acct="levifig" +acct= # pre-existing, unrelated to this spike, untouched throughout +``` + +After creating and authenticating Profiles A, B, and C: + +``` +acct="levifigueira" created=20251208131618 modified=20260708003200 +acct="levifig" created=20240307183508 modified=20260708003201 +acct= (unrelated entry, present before this spike) +``` + +Count of `gh:github.com` entries: **3 before, 3 after** — unchanged. Profile A's and Profile C's logins (both `levifig`) and Profile B's login (`levifigueira`) each **updated `mdat` on the same two pre-existing entries** rather than creating new ones. No new keychain item was created per config dir. + +## (c) Do two profiles for the same account coexist? + +Yes, functionally — Profile A and Profile C both correctly resolve `levifig`'s identity via `gh api user`. But "coexist" understates it: they are not two independent credentials that happen to agree — they are **two pointers into the exact same keychain entry**. There is only one `levifig` secret on the machine; every config dir that points to `user: levifig` reads and (on login) rewrites that single shared item. + +## Verdict + +**Isolation is not real at the credential-storage layer. It is real only at the pointer layer.** + +- `GH_CONFIG_DIR` profiles are directories containing a `hosts.yml` that names which account is "active" for that directory (a pointer, not a credential store). +- The actual secret material lives in exactly one place on the machine: the OS keychain, in an item keyed by `(service = "gh:", account = "")`. This store is **shared globally** — not partitioned by `GH_CONFIG_DIR`, not partitioned by which profile created the login. +- Commands that read the *active* account (`gh api`, `gh auth status`, and — per the companion HTTPS spike — `gh auth git-credential`) are correctly scoped, because they resolve through the config dir's `hosts.yml` pointer first. +- Commands that accept an explicit `--user` (or, per the companion spike, `GH_TOKEN`) **bypass the config dir's pointer entirely** and can retrieve *any* account's token that has ever been authenticated on the machine, from *any* profile — this is a deliberate gh feature (multi-account convenience), not a bug, but it means "profile" is a UX/routing concept, not a security boundary. Any process that can invoke `gh` with `--user ` already has access to that account's token regardless of `GH_CONFIG_DIR`. +- This directly explains the mechanism *class* behind Discussion #188559 even before the HTTPS spike ran it down: gh's own identity-scoping depends entirely on which pointer a given code path consults, and any path that doesn't consult the active-account pointer (or that consults a *different* store, like a coexisting `osxkeychain` git credential — see the HTTPS spike) will diverge from the profile's intended identity. + +**Practical implication for the Hypothesis:** the "no shared mutable state contested between sessions" claim holds for the *pointer* (each config dir's `hosts.yml` is a private file, genuinely uncontested), but does **not** hold for the underlying secret store, which is one shared machine-wide keychain. Two concurrent sessions wired to different profiles will not race on file writes to `hosts.yml`, but they are reading from — and, on any `gh auth login`, writing to — the same keychain entries. This is a nuance the contract's "no shared mutable state" framing should carry: it's true for config-file mutation races (the actual collision cost #99 discloses), not true in an absolute sense about credential storage. + +## Cleanup + +All three temp profile directories were `rm -rf`'d at the end of the spike (verified empty afterward, only the setup script remains under the session scratchpad). No token values were printed anywhere in this transcript or in the terminal at any point; only exit codes, lengths, equality checks, and account-name outputs from `gh api user --jq .login` were captured. The default profile (`~/.config/gh`) was read-only throughout: `gh auth status` before and after this spike shows identical state (`levifig` active, `levifigueira` present and inactive, same scopes). One observed side effect: `~/.config/gh/hosts.yml`'s mtime advanced during the spike (gh appears to rewrite/normalize the file on some read paths, e.g. via the `--user` control test run against the default profile) — content is byte-identical in substance (same accounts, same active pointer, cosmetic YAML formatting only: `levifig: {}` vs `levifigueira:`), confirmed by direct comparison before and after. No logout, no account switch, no `hosts.yml` edit was performed by this spike.