fix(git): keep the agent-terminal credential guard scalar-only so env scrubs can't break git (#38716)

* fix(git): keep the agent-terminal credential guard scalar-only so env scrubs can't break git

Agents that scrub env names matching KEY/TOKEN/... (DeepSeek Harness) dropped
GIT_CONFIG_KEY_n but kept GIT_CONFIG_COUNT, so every git command in those
terminals failed with "missing config key GIT_CONFIG_KEY_0". GCM_INTERACTIVE
already carries credential.interactive and suppresses GCM's GUI, so the guard
now emits only scalar variables. Tracked in #26594.

Co-authored-by: yu.xia <yuxianice@163.com>

* chore(git): drop stale indexed-config comments and a dead WSL forward branch

---------

Co-authored-by: yu.xia <yuxianice@163.com>
This commit is contained in:
Brennan Benson
2026-11-05 16:04:25 -08:00
committed by GitHub
co-authored by yu.xia
parent 27b1ed858c
commit 369e23b084
11 changed files with 51 additions and 143 deletions
@@ -57,25 +57,6 @@ export function readValidGitConfigEnvCount(env: NodeJS.ProcessEnv): number | nul
return hasDanglingIndex ? null : count
}
/** Compose indexed Git config without clobbering caller-provided entries. */
export function appendGitConfigEnv(
env: NodeJS.ProcessEnv,
entries: readonly (readonly [key: string, value: string])[]
): NodeJS.ProcessEnv {
const next = { ...env }
const base = readValidGitConfigEnvCount(env)
if (base === null) {
// Ambiguous indices may hold caller data; use scalar guards instead of overwriting them.
return next
}
entries.forEach(([key, value], index) => {
next[`GIT_CONFIG_KEY_${base + index}`] = key
next[`GIT_CONFIG_VALUE_${base + index}`] = value
})
next.GIT_CONFIG_COUNT = String(base + entries.length)
return next
}
/**
* Disable interactive Git credential UI while preserving cached credentials
* and caller-provided askpass programs.
@@ -84,23 +65,19 @@ export function gitCredentialPromptGuardEnv(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
const next = appendGitConfigEnv(
{
...env,
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
// Why: GCM can ignore terminal/askpass guards and open its own GUI.
GCM_INTERACTIVE: 'never'
},
// Retain cached credentials while disabling only interactive helper fallback.
[
['credential.interactive', 'false'],
['credential.guiPrompt', 'false']
]
)
// Why: scalar guards only. Agents scrub env names matching KEY/TOKEN/..., which drops
// GIT_CONFIG_KEY_n but keeps GIT_CONFIG_COUNT, and Git then refuses every command (#26594).
// GCM_INTERACTIVE is GCM's env form of credential.interactive and also suppresses its GUI.
const next: NodeJS.ProcessEnv = {
...env,
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
// Why: GCM can ignore terminal/askpass guards and open its own GUI.
GCM_INTERACTIVE: 'never'
}
if (platform === 'win32') {
// WSLENV must register the complete indexed config protocol or Git rejects its count.
// WSLENV must forward a caller's indexed config as a complete set or Git rejects its count.
const configKeys =
readValidGitConfigEnvCount(next) === null
? []
@@ -747,19 +747,17 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
await runGit(['-C', worktree, 'update-ref', '-d', backupRef, backupOid])
})
it('degrades indexed credential config safely at the Git 2.31 boundary', async () => {
const guardEnv = gitCredentialPromptGuardEnv({}, 'linux')
await expect(runGit(['status', '--short'], guardEnv)).resolves.toBeDefined()
try {
const result = await runGit(['config', '--get', 'credential.interactive'], guardEnv)
expect(supports(2, 31)).toBe(true)
expect(result.stdout.trim()).toBe('false')
} catch {
// Git 2.25 ignores the indexed variables rather than rejecting commands;
// the scalar prompt guards still provide the baseline fail-fast behavior.
expect(supports(2, 31)).toBe(false)
}
it('keeps git usable when an agent scrubs credential-shaped env names (#26594)', async () => {
// The DeepSeek Harness subprocess filter, recorded in #26594.
const sensitiveEnvPattern = /KEY|PASSWORD|SECRET|TOKEN/i
const scrubbed = Object.fromEntries(
Object.entries(gitCredentialPromptGuardEnv({}, 'linux')).filter(
([key]) => !sensitiveEnvPattern.test(key)
)
)
expect(scrubbed.GIT_TERMINAL_PROMPT).toBe('0')
expect(scrubbed.GCM_INTERACTIVE).toBe('never')
await expect(runGit(['status', '--short'], scrubbed)).resolves.toBeDefined()
})
// Git 2.25–2.49 echoes --end-of-options with --symbolic-full-name but consumes it with --verify; git-history skips the echoed line.