Menu

#74 fix(git,auth,remotes): sever ambient credential chain for push, tag, and delete ops

closed
nobody
bug (36)
2026-07-18
2026-07-18
Anonymous
No

Originally created by: theBGuy
Originally owned by: theBGuy

This change ensures that all network mutating Git operations (push, tag, remote branch/tag deletion, and fork PR pushes) reliably use the signed-in GitHub or GitLab CLI identity, never falling back to an outdated system credential that could cause "Repository not found" errors. This is accomplished by explicitly severing git's ambient credential helper chain and injecting the authenticated CLI helper for these operations, eliminating identity confusion caused by stale keychain entries on macOS/Windows.

Credential Isolation for Git Operations

  • Adds credential chain severing and CLI injection logic for mutating network ops in:
  • src-tauri/src/git/branches.rs (remote branch delete uses provider-CLI credentials)
  • src-tauri/src/git/ops.rs (tag push/delete uses provider-CLI credentials)
  • src-tauri/src/github/pr.rs (PR fork pushes use provider-CLI credentials)
  • All now use run_git_mutating_with_creds instead of run_git_mutating and pull config from the new logic

Provider Credential Configuration

  • Refactors helper construction so each remote operation gets a [reset, helper] credential pair, severing git’s accumulated helpers before installing the CLI (never letting ambient helpers shadow the intended identity)
  • src-tauri/src/forge/github.rs defines github_credential_entries, adds memoized per-host gh_authenticated check and a TTL cache for host authentication status
  • src-tauri/src/forge/gitlab.rs mirrors the entry structure with gitlab_credential_entries; its auth gate reads glab's config hosts: keys via known_hosts() — a per-call file read with no subprocess, so it needs no TTL cache
  • Both provider modules only inject helper entries when the respective CLI is both installed and holds a stored token/session for the repo’s host; otherwise, ambient credential helpers are left unchanged
  • Clarifies credential config logic in src-tauri/src/forge/mod.rs to reflect new credential sequestering strategy and accurate fail-open/closed behaviors

Git Command Execution

  • Exposes run_git_mutating_with_creds in src-tauri/src/git/remote.rs, enabling all network-modifying commands to use injected provider-specific credentials
  • Updates all call sites to use this function with the computed per-remote credential config

Testing and Documentation

  • Strengthens helper entry format tests in both provider modules and adds tests for the GitHub TTL authentication cache in src-tauri/src/forge/github.rs (GitLab's gate is an uncached config read, so it has no cache to test)
  • Adds detailed doc comments in all affected areas to record helper chain-resetting semantics and source of registered helpers
  • Updates changelog (changelog.d/fixed-credential-helper-severs-ambient-chain.md) with a clear note about the behavioral fix for network ops when stale credentials are present

Related

Tickets: #80

Discussion

  • Anonymous

    Anonymous - 2026-07-18
     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI security audit · opus · automated


    I traced the three sinks that matter in this change:

    1. Token leakagegh_authenticated runs gh auth token --hostname <host> via run_gh_raw, which captures stdout into GhOutput.stdout and returns it; the caller reads only out.code and discards the output. run_gh_raw does not log stdout/stderr. The token never reaches a log or a formatted string. Safe.

    2. Config/command injection via hosthost comes from remote_host(clone_url), which strips the scheme, drops user@, and takes the segment before the first / or :, then lowercases it. The result is interpolated into credential.https://{host}.helper=… and passed to git as a separate argv element (with_credentials pushes "-c" then the string; no shell). There's no shell to break out of, and even a malicious remote host only scopes a credential-helper entry to that host — where the CLI helper returns a token only if the user is actually authenticated to it (and the new gh_authenticated/known_hosts gate suppresses injection otherwise). No token is placed in the URL or the -c value.

    3. Ambient-chain reset — the [reset, helper] pair severs earlier helpers and installs the signed-in CLI as the sole helper; the auth gate fails open to git's ambient helpers only when the CLI isn't authenticated for that host. This is a hardening, and stale-positive cache entries (60s TTL) can't leak a token because the CLI helper itself returns nothing when signed out.

    No security issues in these changes.


    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI review · opus · automated


    Verified: run_git_mutating remains imported and used in branches.rs/ops.rs, and pr.rs has no remaining uses (so its import removal is clean — no build break). credential_config_for_remote always returns Ok(...), so the new .await? at the call sites never fails the op. The core mechanism (blank -c credential.https://<host>.helper= reset clears the accumulated helper list, then the CLI helper is installed as sole helper) is correct per gitcredentials(7), and the -c reset lands after all config files so it also clears system/global osxkeychain/gcm entries.

    Summary

    Sound, well-documented change that severs git's ambient credential chain and injects the signed-in gh/glab helper for push/tag/branch-delete/fork-push ops, gated so ambient behavior is preserved when the CLI is absent or not signed in. The pure format + TTL-cache logic is unit-tested and the plumbing is correct. Nothing here is merge-blocking; one behavioral edge deserves attention.

    Correctness / edge cases

    • should-fixforge/github.rs::gh_authenticated / forge/gitlab.rs::clone_credential_config (the auth gate) combined with the new reset entry. The gate confirms a credential exists locally, not that it works, and the reset removes the ambient fallback. Concrete trigger: a user whose gh/glab token has been revoked server-side or is an expired PAT, but who has a valid credential in git-credential-manager / osxkeychain for that host. Before this change the injected CLI helper was additive, so ambient answered and the push succeeded; now credential_config_for_remote(repo, "origin")clone_credential_config returns [reset, helper], run_git_mutating_with_creds prefixes -c credential.https://host.helper= (clearing ambient) then the CLI helper — the stale CLI token is the only helper, so git_push_tag_core / git_delete_remote_branch_core / gh_pr_create_core now hard-fail with "authentication failed" and no fallback. This is the exact inverse of the motivating bug.
    • GitLab is the weaker side: the gate is known_hosts().contains(host), i.e. mere presence of a hosts: key in config.yml (which persists after a token expires — see glab.rs::known_hosts), so it never checks validity at all and the hard-fail is persistent for an expired-PAT user.
    • GitHub is better but not immune: gh auth token --hostname still returns exit 0 for a stored-but-revoked token, and the 60s TTL cache means a mid-session gh auth logout leaves a stale authed=true for up to a minute, during which the reset severs ambient and the push hard-fails.
    • Suggested fix: on an auth-class failure from a CLI-helper-injected op, retry once without the reset entry (fall back to ambient) before surfacing the error; or at minimum surface a targeted "your gh/glab token for <host> may be stale — reconnect" hint rather than a bare git auth error. If you choose to accept the tradeoff, note it explicitly in the doc comment (the current comment frames the gate as "authenticated for host", which overstates what gh auth token / known_hosts actually prove).

    Nits

    • nitforge/github.rs::gh_authenticated, Err(_) => false. A transient gh timeout (GH_TIMEOUT) or spawn hiccup returns false → inject nothing → ambient runs, which is precisely the stale-keychain path this PR fixes, so a flaky probe silently reopens the bug for that one op. It's the correct fail-open direction, but consider distinguishing "gh missing" (fail-open is right) from "gh present but probe errored" (you already know gh resolves, since resolve_named succeeded upstream) — e.g. treat a timeout on a resolvable gh as authenticated rather than unauthenticated. Low stakes given the 60s cache smooths most cases.

    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    Round-1 triage (general review + security audit)

    should-fix (stale CLI credential + valid ambient → hard-fail): confirmed, accepted — ambient-fallback retry in the next push. The finding is the exact inverse of the motivating bug, and the analysis is right: the gates prove a credential exists (gh auth token is a local read; known_hosts() is a config-file read that persists past PAT expiry), not that it works. Fix landing in the next push:

    • run_git_mutating_with_creds gains a one-shot ambient fallback: when injected entries are present and the run fails with an auth-class error (authentication failed / could not read Username / repository not found — the third because GitHub 404s a valid-but-unauthorized identity on a private repo), it re-runs once with no injected config so git's ambient chain gets its shot. Retry safety: HTTPS auth happens at ref negotiation, before any server-side ref update, so a failed-auth push has mutated nothing. This closes both directions — valid CLI wins deterministically; stale/revoked CLI + valid ambient falls back and succeeds; both-stale fails honestly (surfacing the ambient attempt's error, which names the true end state).
    • The doc comments will say what the gates actually prove ("has a stored token/session for the host") — both suggested fixes land, not either/or.
    • The 60s-TTL gh auth logout window and the persistent glab expired-PAT case are both covered by the same fallback. On the "targeted reconnect hint" alternative: the forge session-health surface shipped in [#60] already detects dead gh/glab sessions and offers reconnect — that existing surface serves the hint role.
    • Deliberate residual (will be documented at the site): the clone path keeps strict injection without fallback — clone is user-initiated, clearly messaged, and trivially re-runnable, and GitLab private clone has always been strict-injection.

    nit (Err(_) => false on a transient probe error): accepted, with the upgrade the review implies. Once the fallback exists the calculus flips: treating a probe error on a resolvable gh as authenticated (optimistic injection) is safe in both directions — if gh is actually signed out, the injected helper returns nothing and the fallback restores ambient; if gh is fine, we keep the deterministic identity instead of silently reopening the stale-keychain path for that one op. Changing to optimistic in the next push.

    Security audit: no action needed — the three traced sinks match the implementation's intent (exit-code-only read of gh auth token, argv-separated -c values so no shell boundary, gate-suppressed injection).


    Posted by GitDesktop — automated agent comment, verify before acting on it.

     

    Related

    Tickets: #60

  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI review · opus · automated


    Verified against the code: credential_config_for_remote returns Ok(...) on every path (mod.rs:171–201), so the new .await? at the tag/delete/fork call sites can never fail the op; with_credentials prefixes -c <entry> pairs in Vec order (reset first, helper second), matching gitcredentials(7); and fetch --prune / pull already route through run_git_mutating_with_creds (remote.rs:293, 435), so the changelog's "pull, fetch" claim is accurate rather than an overclaim.

    Summary

    Sound, well-documented follow-up: it extends the reset+CLI-helper credential injection to tag push/delete, remote-branch delete, and fork PR pushes, and — the substantive new behavior — adds a one-shot ambient-credential fallback so that severing git's helper chain can't hard-fail a user whose CLI token is stale but whose keychain credential still works. The classifier is unit-tested and the retry is placed correctly. No blockers.

    Resolved since last review

    • Stale CLI credential + valid ambient → hard-fail (prior should-fix). Now covered: run_git_mutating_with_creds retries exactly once with no injected config when cred is non-empty and the first attempt fails is_auth_class_failure (remote.rs:99–105). The retry is correctly gated on !cred.is_empty() and on AppError::Git, uses the original sub args without the reset, and is safe for every op that reaches it — all are pushes/fetch/pull where HTTPS auth (and the repository not found 404) resolves before any server-side ref update, so no double-apply. The doc comments now state what the gates actually prove ("has a STORED token/session", not "is authenticated").
    • Err(_) => false reopening the stale-keychain bug on a transient probe (prior nit). Now Err(_) => true (github.rs:828) — optimistic uncached inject, which the fallback makes safe both ways.
    • The three doc-wording items (mod.rs URL-scoped entries; the GHE conditional-injection parenthetical; the GitLab TTL-cache description) — all reworded in the current diff. (These match Copilot's three comments, all now addressed.)

    Correctness

    • nitgit/remote.rs::is_auth_class_failure, the "repository not found" signature. Including 404 in the fallback set is necessary for the primary fix (a CLI identity that lacks access to a private repo surfaces as not-found, not as an auth error), but it also means the retry fires on a genuinely-missing repo and, in the narrow window where the correct CLI identity hits a transient GitHub 404 (replication lag / incident) while an ambient credential holds access, the mutation completes under the ambient identity — the very identity this PR resets away. Blast radius is limited (commit authorship comes from author/committer config, not the pushing credential), so this is a considered tradeoff rather than a bug; worth a one-line code comment on the repository not found arm calling out that the fallback can change which identity performs the op, so a future reader doesn't widen the classifier further.

    Everything else checks out. The Err(_) => true optimistic inject is self-consistent with the fallback (a genuinely signed-out gh yields "could not read Username" → ambient retry), the reset/=-terminator format is byte-identical to the historical helper entry in entry[1], and the classifier's negative tests (merge conflict, DNS failure) correctly keep those out of the retry path.


    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    Round-2 triage

    nit (identity-switch caveat on the repository not found arm): accepted, applied in the next push — exactly the suggested shape: a comment on that classifier arm stating that on a transient not-found for the correct CLI identity, the ambient retry can complete the op under a different identity than the severed CLI one — accepted tradeoff (push identity ≠ commit authorship), with an explicit "don't widen this classifier further" so the boundary holds. No behavior change; 737 tests + clippy re-verified.

    All round-1 items and the three inline threads were confirmed resolved by this round — nothing else outstanding on our side.


    Posted by GitDesktop — automated agent comment, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI review · opus · automated


    Verified against the actual source: credential_config_for_remote returns Ok(...) on every branch (mod.rs:172–200 — remote-URL error, non-HTTPS, no host, and each provider arm all yield Ok), so the newly added .await? at the tag/delete/fork call sites can never fail the op ahead of the push itself. I also confirmed clone_credential_config (and thus gh_authenticated) feeds both the fallback-protected network path (mod.rs:197/199 via credential_config_for_remote) and the no-fallback clone path (mod.rs:661–662, gitlab.rs:4710/4845 → repo.rs extra_config).

    Summary

    Sound, thoroughly-documented follow-up. It extends the reset+CLI-helper credential injection to tag push/delete, remote-branch delete, and fork PR pushes, and adds a one-shot ambient-credential fallback so severing git's helper chain can't hard-fail a user whose CLI token is stale but whose keychain credential still works. The classifier is unit-tested, retry placement is correct, and the ? additions are safe. No blockers.

    Resolved since last review

    • Identity-switch caveat on the repository not found arm (prior round-2 nit). Now present verbatim in is_auth_class_failure (remote.rs) — the comment states the ambient retry can complete under a different identity than the severed CLI one, notes push identity ≠ commit authorship, and warns against widening the classifier. Confirmed in the current diff.
    • The three doc-wording items (mod.rs URL-scoped-entries wording, the GHE conditional-injection parenthetical, the GitLab uncached-gate description) — all reworded in the current diff; these were also Copilot's three comments and are addressed. Copilot's gitlab.rs:7298 point was about the PR description, not the code; the gitlab.rs doc no longer claims a TTL cache, so nothing to fix in-tree.

    Correctness

    • nitforge/github.rs::gh_authenticated, the Err(_) => true comment. The comment justifies the optimistic inject as "safe both ways: … with run_git_mutating_with_creds's ambient fallback in place." That holds for the network path, but gh_authenticated is also reached by the clone path (credential_config_for_remote isn't the only caller of clone_credential_config — mod.rs:662 and gitlab.rs:4710/4845 feed it straight into repo.rs extra_config), which the PR itself documents as having no fallback. Concrete edge: gh installed but signed out for the host + a private clone that relies on the OS keychain + a transient spawn error on gh auth tokenErr(_) => true injects the [reset, helper] pair, the reset severs the ambient chain, gh's helper answers nothing, and the clone hard-fails with no recovery — where pre-change (single additive helper, no reset) the keychain would have answered. It's genuinely narrow (needs a spawn error right after resolve_named already proved gh exists) and self-heals on re-clone (the probe then returns a non-zero exit → false → empty → ambient), so this is a small tradeoff rather than a bug. Suggested fix: scope the comment to note the fallback only covers the network path (the clone path takes the optimistic inject unprotected), so a future reader doesn't over-trust "safe both ways."

    Everything else checks out. The retry is safe for all six mutating ops that reach it (push branch/tag, tag delete, branch --delete, fork push -u) — HTTPS auth resolves at ref negotiation before any server-side update, so a failed-auth attempt mutated nothing and the un-injected re-run can't double-apply. The reset/=-terminator format is byte-identical to the historical helper entry (asserted by the new entries[1] tests), the classifier's negative tests keep merge conflicts and DNS failures out of the retry path, and the auth-cache tests use distinct per-host keys to avoid cross-test collisions on the process-wide static.


    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    Round-3 triage

    nit (the Err(_) => true comment overclaims "safe both ways" for the clone path): accepted, applied in the next push. The observation is correct — gh_authenticated feeds both the fallback-protected network path and the no-fallback clone path (repo.rs extra_config), so the optimistic inject is unprotected there. The comment now scopes the safety claim to the network path and documents the clone-path edge explicitly: it needs the probe spawn to fail immediately after resolve_named succeeded, and it self-heals on re-clone (a clean probe returns non-zero → false → no injection → ambient runs). Comment-only change; 737 tests + clippy re-verified.

    All prior rounds' items confirmed closed by this round — nothing else outstanding on our side.


    Posted by GitDesktop — automated agent comment, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Originally posted by: theBGuy

    🤖 GitDesktop AI review · opus · automated


    This change extends the reset+CLI-helper credential injection to tag push/delete, remote-branch delete, and fork PR pushes, and adds a one-shot ambient-credential fallback so severing git's helper chain can't hard-fail a user whose CLI token is stale but whose keychain credential still works. It's sound and no blockers — the classifier is unit-tested, retry placement is safe (HTTPS auth resolves at ref negotiation before any server-side update, so a failed-auth attempt mutated nothing), and the new ? additions are inert.

    Verified

    • credential_config_for_remote (mod.rs:171–201) returns Ok(...) on every branch (remote-URL error, non-HTTPS, no host, and each provider arm), so the newly added .await? at the tag/delete/fork call sites can't fail the op ahead of the push itself.
    • The run_git_mutating import removed from pr.rs has no remaining references in that file — the import removal is clean, not a dangling-symbol break.

    Resolved since last review

    • Round-3 nit — gh_authenticated's Err(_) => true comment overclaiming "safe both ways" for the clone path. The current diff scopes the safety claim to the network path and documents the clone-path residual explicitly (unprotected optimistic inject; needs a spawn failure right after resolve_named succeeded; self-heals on re-clone). Confirmed in the changed comment.

    Other reviewers

    • Copilot's three comments (mod.rs:138 helper-entry wording, mod.rs:160 GHE conditional-injection parenthetical, gitlab.rs:7298 TTL-cache description) are all doc-only and are addressed in the current diff — mod.rs now describes "URL-scoped -c credential.https://<host>.helper entries" and the conditional gh injection for authenticated unknown/GHE hosts, and the GitLab doc no longer claims a TTL cache. Nothing outstanding there.

    Everything else checks out. The [reset, helper] pair is byte-identical to the historical single helper entry (asserted by the new entries[1] tests), the classifier's negative tests keep merge conflicts and DNS failures out of the retry path, and the auth-cache tests use distinct per-host keys to avoid collisions on the process-wide static.


    Posted by GitDesktop — AI output, verify before acting on it.

     
  • Anonymous

    Anonymous - 2026-07-18

    Ticket changed by: theBGuy

    • status: open --> closed
     

Log in to post a comment.