Files
orca/src/main/git/command-runner/git-ssh-policy-env.ts
T

187 lines
5.3 KiB
TypeScript

import { addWslEnvKeys } from '../../wsl-env'
import { quotePosixShell } from '../../../shared/wsl-login-shell-command'
import { execFileCapture } from './exec-file-capture'
import { resolveGitCommand } from './git-command-resolution'
import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options'
import { promptGuardGitEnv } from './git-process-env'
import { acquireGitAdmission } from './git-subprocess-admission'
export type GitSshPolicyMode =
| 'default'
| 'explicit-env'
| 'fallback'
| 'configured-openssh'
| 'configured-wrapper-passthrough'
const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500
function commandBasename(command: string): string {
const pieces = command.split(/[\\/]+/)
return pieces.at(-1)?.toLowerCase() ?? command.toLowerCase()
}
function isMergeableOpenSshCommand(command: string): boolean {
const basename = commandBasename(command)
return basename === 'ssh' || basename === 'ssh.exe'
}
function shellTokenize(command: string): string[] | null {
const tokens: string[] = []
let current = ''
let quote: "'" | '"' | null = null
let escaped = false
for (let i = 0; i < command.length; i++) {
const char = command[i]
if (escaped) {
current += char
escaped = false
continue
}
if (char === '\\') {
const next = command[i + 1]
if (next && /[\s'"\\]/.test(next)) {
escaped = true
} else {
current += char
}
continue
}
if (quote) {
if (char === quote) {
quote = null
} else {
current += char
}
continue
}
if (char === "'" || char === '"') {
quote = char
continue
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current)
current = ''
}
continue
}
if (';&|<>()`'.includes(char)) {
return null
}
current += char
}
if (escaped || quote) {
return null
}
if (current) {
tokens.push(current)
}
return tokens
}
function shellQuoteToken(token: string): string {
return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(token) ? token : quotePosixShell(token)
}
function containsShellExpansionSyntax(command: string): boolean {
return command.includes('$')
}
function withoutBatchModeOptions(tokens: string[]): string[] {
const next: string[] = []
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const lower = token.toLowerCase()
if (lower === '-o') {
const option = tokens[i + 1]?.toLowerCase()
if (option?.startsWith('batchmode')) {
i += 1
continue
}
}
if (lower.startsWith('-obatchmode')) {
continue
}
next.push(token)
}
return next
}
function buildOpenSshBatchModeCommand(configuredCommand: string): string | null {
if (containsShellExpansionSyntax(configuredCommand)) {
return null
}
const tokens = shellTokenize(configuredCommand)
if (!tokens || tokens.length === 0 || !isMergeableOpenSshCommand(tokens[0])) {
return null
}
return [...withoutBatchModeOptions(tokens), '-o', 'BatchMode=yes'].map(shellQuoteToken).join(' ')
}
export async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{
env: NodeJS.ProcessEnv
mode: GitSshPolicyMode
}> {
const promptEnv = promptGuardGitEnv(options.env)
if (promptEnv.GIT_SSH_COMMAND) {
return { env: promptEnv, mode: 'explicit-env' }
}
// Why fenced: a login-shell banner here reads as a user-configured sshCommand,
// which skips the BatchMode fallback below and disarms the no-prompt guard.
const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true, true)
const probeArgs = ['config', '--get', 'core.sshCommand']
const grant = await acquireGitAdmission({
args: probeArgs,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.signal
})
let reportTerminated: () => void = () => {}
const terminated = new Promise<void>((resolve) => {
reportTerminated = resolve
})
let configuredCommand = ''
try {
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'utf-8',
maxBuffer: DEFAULT_GIT_MAX_BUFFER,
timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS,
env: promptEnv,
signal: options.signal,
onChildTerminated: reportTerminated
})
const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout)
configuredCommand = payload.trim()
} catch {
configuredCommand = ''
} finally {
void terminated.then(grant.release)
}
if (!configuredCommand) {
const env = { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
// Why: WSL routing can come from either an explicit distro or a UNC cwd.
if (resolved.wsl) {
addWslEnvKeys(env, ['GIT_SSH_COMMAND'])
}
return { env, mode: 'fallback' }
}
const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand)
if (!batchModeCommand) {
// Why: custom SSH wrappers are user policy; rewriting their argv is riskier than relying on prompt guards + timeout.
return { env: promptEnv, mode: 'configured-wrapper-passthrough' }
}
const env = { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand }
if (resolved.wsl) {
addWslEnvKeys(env, ['GIT_SSH_COMMAND'])
}
return { env, mode: 'configured-openssh' }
}