From 9d2e9e75c6ecf89241b8aca67825d5305e0886ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:39:05 -0700 Subject: [PATCH] fix(terminal): restore only the WSLENV names the credential guard forwards The guard forwards `GIT_TERMINAL_PROMPT`, `GCM_INTERACTIVE` and the indexed config protocol into WSLENV -- never `GIT_ASKPASS`/`SSH_ASKPASS`. Restoration removed all four scalars, so a WSLENV token the guard never added was stripped from an unguarded child purely because it postdated the pre-guard snapshot, destroying the user's own WSL forwarding. Both sides now read one exported constant, so the ratchet keeps them coupled. --- .../git-credential-guard-provenance.test.ts | 20 +++++++++++++++++++ src/shared/git-credential-guard-provenance.ts | 5 ++++- src/shared/git-credential-prompt-env.ts | 11 +++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/shared/git-credential-guard-provenance.test.ts b/src/shared/git-credential-guard-provenance.test.ts index 11ee6c35d14..8b288d11d09 100644 --- a/src/shared/git-credential-guard-provenance.test.ts +++ b/src/shared/git-credential-guard-provenance.test.ts @@ -98,6 +98,26 @@ describe('restoreUnguardedGitCredentialEnv marker handling', () => { expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt') }) + // The guard forwards only its scalars and the indexed-config protocol into + // WSLENV -- never the askpass names. Removing a name the guard never added + // strips the user's own WSL forwarding, which is the leak's mirror image. + it('keeps a WSLENV name the guard never forwards, even when it appeared after the guard', () => { + const saved = takeGitCredentialGuardEnv() + try { + const env: Record = { WSLENV: 'MY_VAR/u' } + const pre = captureGitCredentialGuardPreGuardState(env) + Object.assign(env, gitCredentialPromptGuardEnv(env, 'win32') as Record) + recordGitCredentialGuardProvenance(env, pre, { appendedConfig: true, forwardToWsl: true }) + // The user forwards their own askpass into WSL from inside the guarded pane. + env.WSLENV = `${env.WSLENV}:GIT_ASKPASS/p` + + expect(restoreUnguardedGitCredentialEnv(env)).toBe(true) + expect(env.WSLENV).toBe('MY_VAR/u:GIT_ASKPASS/p') + } finally { + restoreGitCredentialGuardEnv(saved) + } + }) + // A marker that names no config base did not append config, so nothing indexed // in this environment is Orca's — including a pair that looks exactly like ours. it('never removes indexed config when the marker claims no config base', () => { diff --git a/src/shared/git-credential-guard-provenance.ts b/src/shared/git-credential-guard-provenance.ts index 71537f40b38..1b61e5fb85d 100644 --- a/src/shared/git-credential-guard-provenance.ts +++ b/src/shared/git-credential-guard-provenance.ts @@ -1,5 +1,6 @@ import { GIT_CREDENTIAL_GUARD_CONFIG_ENTRIES, + GIT_CREDENTIAL_GUARD_WSLENV_SCALAR_KEYS, readValidGitConfigEnvCount } from './git-credential-prompt-env' import { addWslEnvKeys } from './wsl-env' @@ -187,9 +188,11 @@ function restoreWslEnv(env: Record, state: RestoreState): void { if (priorNames.has(name)) { return true } + // Why: the guard never forwards the askpass names, so a WSLENV token it + // never added is the user's even when it postdates the pre-guard snapshot. return !( name === TERMINAL_GIT_CREDENTIAL_GUARD_RESTORE_ENV || - (SCALAR_KEYS as readonly string[]).includes(name) || + (GIT_CREDENTIAL_GUARD_WSLENV_SCALAR_KEYS as readonly string[]).includes(name) || GIT_CONFIG_PROTOCOL_KEY_RE.test(name) ) }) diff --git a/src/shared/git-credential-prompt-env.ts b/src/shared/git-credential-prompt-env.ts index f2f266cd1e6..ac3e9eac8c4 100644 --- a/src/shared/git-credential-prompt-env.ts +++ b/src/shared/git-credential-prompt-env.ts @@ -89,6 +89,15 @@ export const GIT_CREDENTIAL_GUARD_CONFIG_ENTRIES = [ ['credential.guiPrompt', 'false'] ] as const satisfies readonly (readonly [key: string, value: string])[] +/** + * The scalar guard variables the guard forwards into WSL. Provenance reads it so + * restoration removes exactly the names the guard adds to WSLENV and no others. + */ +export const GIT_CREDENTIAL_GUARD_WSLENV_SCALAR_KEYS = [ + 'GIT_TERMINAL_PROMPT', + 'GCM_INTERACTIVE' +] as const + /** * Disable interactive Git credential UI while preserving cached credentials * and caller-provided askpass programs. @@ -115,7 +124,7 @@ export function gitCredentialPromptGuardEnv( readValidGitConfigEnvCount(next) === null ? [] : Object.keys(next).filter((key) => GIT_CONFIG_WSLENV_KEY_RE.test(key)) - addWslEnvKeys(next, ['GIT_TERMINAL_PROMPT', 'GCM_INTERACTIVE', ...configKeys]) + addWslEnvKeys(next, [...GIT_CREDENTIAL_GUARD_WSLENV_SCALAR_KEYS, ...configKeys]) } return next }