fix(terminal): restore each credential-guard marker against the env it was stamped into

The daemon merges its inherited process env with the request main computed, and
both can carry a provenance marker. The request's marker won the merge, so the
guard the daemon inherited from the pane Orca was launched from was never undone:
its config pair survived and the fresh guard appended a second identical pair
(GIT_CONFIG_COUNT 5, not 3), and the marker the pane then carried recorded the
wire's priors instead of the user's, so a plain shell launched inside it kept
credential.interactive=false.

Undo each marker on its own side of the merge. Adds provenance unit tests for the
unreadable-marker and unknown-version fall-through, the scalar/config/WSLENV
ownership gates, and the deferred-marker config-base guard.
This commit is contained in:
Brennan Benson
2026-08-28 06:09:10 -07:00
parent 8d14cb0626
commit c62d76d5ee
4 changed files with 278 additions and 4 deletions
@@ -308,4 +308,62 @@ describe('createPtySubprocess', () => {
restoreGitCredentialGuardEnv(saved)
}
})
it('does not double the guard when the daemon inherited one and the request carries its own', async () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
// Orca was launched from a guarded agent pane, so the daemon it forked
// inherits that pane's guard AND that pane's provenance marker.
const inherited: Record<string, string> = {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.quotePath',
GIT_CONFIG_VALUE_0: 'false',
GIT_TERMINAL_PROMPT: '1',
GCM_INTERACTIVE: 'auto'
}
applyTerminalGitCredentialPromptGuard(inherited, {
launchCommand: 'claude',
platform: process.platform
})
const saved = takeGitCredentialGuardEnv(inherited)
try {
// Exactly what main puts on the wire for a guarded pane it defers to the daemon.
const requested: Record<string, string> = {}
applyTerminalGitCredentialPromptGuard(requested, {
launchCommand: 'claude',
platform: process.platform,
deferGitConfigGuardToHost: true
})
await createPtySubprocess({
sessionId: 'inherited-and-requested-guard',
cols: 80,
rows: 24,
env: { SHELL: '/bin/bash', ...requested }
})
const paneEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
// One caller entry plus one guard pair — not two guard pairs.
expect(paneEnv.GIT_CONFIG_COUNT).toBe('3')
expect(paneEnv.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(paneEnv.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect(paneEnv.GIT_CONFIG_KEY_3).toBeUndefined()
// And the pane's own marker still describes the user's real pre-guard env,
// so a plain shell launched inside it lands exactly back on those values.
const child = { ...paneEnv }
expect(
applyTerminalGitCredentialPromptGuard(child, {
launchCommand: '/bin/zsh',
platform: process.platform
})
).toBe(false)
expect(child.GIT_TERMINAL_PROMPT).toBe('1')
expect(child.GCM_INTERACTIVE).toBe('auto')
expect(child.GIT_CONFIG_COUNT).toBe('1')
expect(child.GIT_CONFIG_KEY_0).toBe('core.quotePath')
expect(Object.values(child)).not.toContain('credential.interactive')
} finally {
restoreGitCredentialGuardEnv(saved)
}
})
})
@@ -39,9 +39,6 @@ function composeGuardedDaemonGitConfigEnv(
): void {
const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
// Why: the daemon forks from Electron and can be launched from a guarded pane, so
// its inherited env may already carry the guard; undo it before deciding afresh.
restoreUnguardedGitCredentialEnv(env)
if (policy !== 'guard' && launchAgent === undefined) {
return
}
@@ -145,8 +142,19 @@ function removeInheritedDevAgentHookEndpoint(
}
export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record<string, string> {
// Why: a marker only describes the environment it was stamped into. The daemon
// forks from Electron and can be launched from a guarded pane, and the request
// carries its own marker for the scalars main wrote on the wire — undo them on
// their own side, or the request's marker lands on top of the inherited one and
// the guard it was meant to explain survives the merge unexplained.
const inheritedEnv = stripInheritedBuildModeEnv(process.env) as Record<string, string>
restoreUnguardedGitCredentialEnv(inheritedEnv)
const requestedEnv = opts.env ? { ...opts.env } : undefined
if (requestedEnv) {
restoreUnguardedGitCredentialEnv(requestedEnv)
}
const env: Record<string, string> = {
...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env),
...mergeGitConfigEnvProtocol(inheritedEnv, requestedEnv),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import {
captureGitCredentialGuardPreGuardState,
recordGitCredentialGuardProvenance,
restoreUnguardedGitCredentialEnv,
TERMINAL_GIT_CREDENTIAL_GUARD_RESTORE_ENV as MARKER
} from './git-credential-guard-provenance'
/** An env carrying the guard, marked with the provenance `raw` describes. */
function guardedEnv(raw: string): Record<string, string> {
return {
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never',
GIT_ASKPASS: '',
SSH_ASKPASS: '',
GIT_CONFIG_COUNT: '2',
GIT_CONFIG_KEY_0: 'credential.interactive',
GIT_CONFIG_VALUE_0: 'false',
GIT_CONFIG_KEY_1: 'credential.guiPrompt',
GIT_CONFIG_VALUE_1: 'false',
[MARKER]: raw
}
}
const INTACT_GUARD = {
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never',
GIT_ASKPASS: '',
SSH_ASKPASS: '',
GIT_CONFIG_COUNT: '2',
GIT_CONFIG_KEY_0: 'credential.interactive',
GIT_CONFIG_VALUE_0: 'false',
GIT_CONFIG_KEY_1: 'credential.guiPrompt',
GIT_CONFIG_VALUE_1: 'false'
}
describe('restoreUnguardedGitCredentialEnv marker handling', () => {
it('leaves everything alone when there is no marker at all', () => {
const env = { ...INTACT_GUARD }
expect(restoreUnguardedGitCredentialEnv(env)).toBe(false)
expect(env).toEqual(INTACT_GUARD)
})
// A marker Orca cannot read cannot tell it which values are Orca's, so the
// arm it falls to must be "change nothing" — never "clear the user's Git env".
it.each([
['truncated json', '{"v":1,"env":{"GIT_TERMINAL_PROM'],
['not json at all', 'guard'],
['a json non-object', '"guard"'],
['a future marker version', '{"v":2,"env":{"GIT_TERMINAL_PROMPT":"1"},"configBase":0}'],
['a missing version', '{"env":{"GIT_TERMINAL_PROMPT":"1"},"configBase":0}'],
['a non-object env', '{"v":1,"env":"GIT_TERMINAL_PROMPT","configBase":0}']
])('leaves the guard intact for %s', (_label, raw) => {
const env = guardedEnv(raw)
expect(restoreUnguardedGitCredentialEnv(env)).toBe(false)
expect(env).toEqual(INTACT_GUARD)
})
it('drops an unreadable marker so it cannot be mistaken for provenance later', () => {
const env = guardedEnv('{"v":1,"env":{')
restoreUnguardedGitCredentialEnv(env)
expect(Object.hasOwn(env, MARKER)).toBe(false)
})
it('ignores a marker whose recorded values are not strings or null', () => {
const env = guardedEnv(
'{"v":1,"env":{"GIT_TERMINAL_PROMPT":7,"GCM_INTERACTIVE":null},"configBase":null}'
)
expect(restoreUnguardedGitCredentialEnv(env)).toBe(true)
// 7 is not a recorded value, so the key is not Orca's to restore.
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBeUndefined()
})
it('ignores a configBase that is not a safe integer', () => {
for (const base of ['"0"', '0.5', '1e400', 'true']) {
const env = guardedEnv(`{"v":1,"env":{},"configBase":${base}}`)
restoreUnguardedGitCredentialEnv(env)
expect(env.GIT_CONFIG_COUNT).toBe('2')
expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
}
})
// 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', () => {
const env = guardedEnv('{"v":1,"env":{"GIT_TERMINAL_PROMPT":null},"configBase":null}')
expect(restoreUnguardedGitCredentialEnv(env)).toBe(true)
expect(env.GIT_TERMINAL_PROMPT).toBeUndefined()
expect(env.GIT_CONFIG_COUNT).toBe('2')
expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
expect(env.GIT_CONFIG_VALUE_0).toBe('false')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt')
})
it('round-trips a capture through a record and back to the captured values', () => {
const original = {
GIT_TERMINAL_PROMPT: '1',
GIT_ASKPASS: '/usr/local/bin/ask',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
const env: Record<string, string> = { ...original }
const pre = captureGitCredentialGuardPreGuardState(env)
Object.assign(env, {
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never',
SSH_ASKPASS: '',
GIT_CONFIG_COUNT: '3',
GIT_CONFIG_KEY_1: 'credential.interactive',
GIT_CONFIG_VALUE_1: 'false',
GIT_CONFIG_KEY_2: 'credential.guiPrompt',
GIT_CONFIG_VALUE_2: 'false'
})
recordGitCredentialGuardProvenance(env, pre, { appendedConfig: true, forwardToWsl: false })
expect(restoreUnguardedGitCredentialEnv(env)).toBe(true)
expect(env).toEqual(original)
})
})
@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
restoreGitCredentialGuardEnv,
takeGitCredentialGuardEnv
} from './git-credential-guard-env-test-harness'
import { TERMINAL_GIT_CREDENTIAL_GUARD_RESTORE_ENV } from './git-credential-guard-provenance'
import {
applyTerminalGitCredentialPromptGuard,
@@ -213,4 +217,87 @@ describe('guard env inherited by a terminal the guard declines to guard', () =>
expect(env.GIT_CONFIG_COUNT).toBe('2')
})
it('keeps a guard variable the pane itself re-exported', () => {
const parent = guardedParentEnv('linux')
// The pane's own shell rc re-enabled prompting after Orca guarded the pane.
parent.GIT_TERMINAL_PROMPT = '1'
parent.GCM_INTERACTIVE = 'auto'
const { env } = spawnChild(parent, { launchCommand: '/bin/zsh', platform: 'linux' })
expect(env.GIT_TERMINAL_PROMPT).toBe('1')
expect(env.GCM_INTERACTIVE).toBe('auto')
})
it('keeps a guard config slot whose value the pane overrode', () => {
const parent = guardedParentEnv('linux')
// Someone inside the pane flipped Orca's own entry back on.
parent.GIT_CONFIG_VALUE_0 = 'true'
const { env } = spawnChild(parent, { launchCommand: '/bin/zsh', platform: 'linux' })
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
expect(env.GIT_CONFIG_VALUE_0).toBe('true')
})
it("keeps the caller's own WSLENV token for a guard variable, flags and all", () => {
const saved = takeGitCredentialGuardEnv()
try {
const parent = guardedParentEnv('win32', { WSLENV: 'GIT_ASKPASS/p' })
const { env } = spawnChild(parent, { launchCommand: '/bin/zsh', platform: 'win32' })
expect(env.WSLENV).toBe('GIT_ASKPASS/p')
} finally {
restoreGitCredentialGuardEnv(saved)
}
})
it('removes WSLENV rather than leaving an empty one behind', () => {
const saved = takeGitCredentialGuardEnv()
try {
const parent = guardedParentEnv('win32')
expect(parent.WSLENV).toBeTruthy()
const { env } = spawnChild(parent, { launchCommand: '/bin/zsh', platform: 'win32' })
expect(Object.hasOwn(env, 'WSLENV')).toBe(false)
} finally {
restoreGitCredentialGuardEnv(saved)
}
})
it('never lets a detached-host marker remove indexed config the host owns', () => {
const wire: Record<string, string> = { PATH: '/usr/bin' }
expect(
applyTerminalGitCredentialPromptGuard(wire, {
launchCommand: 'claude',
platform: 'linux',
deferGitConfigGuardToHost: true
})
).toBe(true)
// The host's own environment already hardens Git the same way Orca does.
const host: Record<string, string> = {
...wire,
GIT_CONFIG_COUNT: '2',
GIT_CONFIG_KEY_0: 'credential.interactive',
GIT_CONFIG_VALUE_0: 'false',
GIT_CONFIG_KEY_1: 'credential.guiPrompt',
GIT_CONFIG_VALUE_1: 'false'
}
delete host[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
expect(
applyTerminalGitCredentialPromptGuard(host, {
launchCommand: '/bin/zsh',
platform: 'linux'
})
).toBe(false)
expect(host.GIT_CONFIG_COUNT).toBe('2')
expect(host.GIT_CONFIG_KEY_0).toBe('credential.interactive')
expect(host.GIT_CONFIG_VALUE_0).toBe('false')
expect(host.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt')
})
})