Skip to content
189 changes: 189 additions & 0 deletions packages/core/src/extension/extension-git-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { execSync } from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { simpleGit } from 'simple-git';
import { createExtensionGitClient } from './extension-git-client.js';

describe('createExtensionGitClient', () => {
let tempDir: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'extension-git-client-'));
});

afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});

it('runs authenticated Git commands with pinned network config', async () => {
let spawned = false;
const git = createExtensionGitClient(simpleGit, {
baseDir: tempDir,
networkPolicy: 'public',
networkConfig: [
'http.curloptResolve=git.example.com:443:8.8.8.8',
'http.followRedirects=false',
'http.proxy=',
'protocol.allow=never',
'protocol.https.allow=always',
],
authentication: {
source: 'https://git.example.com/owner/repo.git',
credential: { username: 'user', password: 'token' },
},
});
git.outputHandler(() => {
spawned = true;
});

await expect(git.version()).resolves.toBeDefined();
expect(spawned).toBe(true);
});

it('runs authenticated Git commands without pinned network config', async () => {
let spawned = false;
const git = createExtensionGitClient(simpleGit, {
baseDir: tempDir,
authentication: {
source: 'https://git.example.com/owner/repo.git',
credential: { username: 'user', password: 'token' },
},
});
git.outputHandler(() => {
spawned = true;
});

await expect(git.version()).resolves.toBeDefined();
expect(spawned).toBe(true);
});

it.each([
'http://git.example.com/owner/repo.git',
'--upload-pack=attacker-command',
])('rejects credentialed non-HTTPS Git source %s', (source) => {
expect(() =>
createExtensionGitClient(simpleGit, {
baseDir: tempDir,
authentication: {
source,
credential: { username: 'user', password: 'token' },
},
}),
).toThrow(
'Credentialed Git operations require a valid HTTPS repository URL.',
);
});

it.each([
['alias', 'https://github.com/owner/alias-service.git'],
[
'credential helper',
'https://git.example.com/owner/credential.helper.git',
],
])(
'runs authenticated Git commands when the URL contains %s text',
async (_label, source) => {
let spawned = false;
const git = createExtensionGitClient(simpleGit, {
baseDir: tempDir,
authentication: {
source,
credential: { username: 'user', password: 'token' },
},
});
git.outputHandler(() => {
spawned = true;
});

await expect(git.version()).resolves.toBeDefined();
expect(spawned).toBe(true);
},
);

it('does not allow unrelated unsafe config for a URL false positive', async () => {
let spawned = false;
const git = createExtensionGitClient(simpleGit, {
baseDir: tempDir,
authentication: {
source: 'https://github.com/owner/alias-service.git',
credential: { username: 'user', password: 'token' },
},
});
git.outputHandler(() => {
spawned = true;
});

await expect(
git.raw(['-c', 'credential.helper=store', '--version']),
).rejects.toThrow('allowUnsafeCredentialHelper');
expect(spawned).toBe(false);
});

it('does not inherit Git config count for anonymous public commands', async () => {
vi.stubEnv('GIT_CONFIG_COUNT', '1');
vi.stubEnv('GIT_CONFIG_KEY_0', 'http.extraHeader');
vi.stubEnv('GIT_CONFIG_VALUE_0', 'Authorization: Basic external');
let spawned = false;
const git = createExtensionGitClient(simpleGit, {
baseDir: tempDir,
networkPolicy: 'public',
});
git.outputHandler(() => {
spawned = true;
});

await expect(git.version()).resolves.toBeDefined();
expect(spawned).toBe(true);
});

it.runIf(process.platform !== 'win32')(
'runs restricted Git commands through a PATH wrapper needing a non-allowlisted variable',
async () => {
const realGit = execSync('command -v git', { encoding: 'utf8' })
.trim()
.split(/\r?\n/)[0];
expect(realGit).toBeTruthy();
const wrapperDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'extension-git-wrapper-'),
);
await fs.writeFile(
path.join(wrapperDir, 'git'),
[
'#!/bin/sh',
'if [ -z "$QC_WRAPPER_REAL_GIT" ]; then',
' echo "QC_WRAPPER_REAL_GIT missing" >&2',
' exit 127',
'fi',
'exec "$QC_WRAPPER_REAL_GIT" "$@"',
'',
].join('\n'),
{ mode: 0o755 },
);
vi.stubEnv(
'PATH',
`${wrapperDir}${path.delimiter}${process.env['PATH'] ?? ''}`,
);
vi.stubEnv('QC_WRAPPER_REAL_GIT', realGit);
let spawned = false;
const git = createExtensionGitClient(simpleGit, {
baseDir: tempDir,
networkPolicy: 'public',
});
git.outputHandler(() => {
spawned = true;
});

await expect(git.version()).resolves.toBeDefined();
expect(spawned).toBe(true);
},
);
});
172 changes: 172 additions & 0 deletions packages/core/src/extension/extension-git-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import * as os from 'node:os';
import type { SimpleGit, SimpleGitFactory, SimpleGitOptions } from 'simple-git';
import type { ExtensionInstallMetadata } from '../config/config.js';
import type { GitCredential } from './extension-git-credentials.js';

interface ExtensionGitAuthentication {
source: string;
credential: GitCredential;
}

interface ExtensionGitClientOptions {
baseDir: string;
signal?: AbortSignal;
networkPolicy?: ExtensionInstallMetadata['networkPolicy'];
networkConfig?: string[];
authentication?: ExtensionGitAuthentication;
}

type UnsafeOptions = NonNullable<SimpleGitOptions['unsafe']>;

function assertCredentialedHttpsSource(source: string): void {
try {
if (new URL(source).protocol === 'https:') return;
Comment thread
callmeYe marked this conversation as resolved.
Resolved
} catch {
// Use the same redacted error for malformed and non-HTTPS sources.
}
throw new Error(
'Credentialed Git operations require a valid HTTPS repository URL.',
);
}

// Mirrors the unsafe config-key blocklist matchers in @simple-git/argv-parser
// (1.1.1 via simple-git 3.36). Re-sync this table on a dependency bump: a new
// matcher that this table misses fails credentialed installs pre-spawn.
const generatedConfigKeyAllowances = [
Comment thread
callmeYe marked this conversation as resolved.
Resolved
[/alias/, 'allowUnsafeAlias'],
Comment thread
callmeYe marked this conversation as resolved.
Resolved
Comment thread
callmeYe marked this conversation as resolved.
Resolved
Comment thread
callmeYe marked this conversation as resolved.
Resolved
[/core.askpass/, 'allowUnsafeAskPass'],
Comment thread
callmeYe marked this conversation as resolved.
Resolved
[/core.editor/, 'allowUnsafeEditor'],
[/core.fsmonitor/, 'allowUnsafeFsMonitor'],
[/core.gitproxy/, 'allowUnsafeGitProxy'],
[/core.hookspath/, 'allowUnsafeHooksPath'],
[/core.pager/, 'allowUnsafePager'],
[/core.sshcommand/, 'allowUnsafeSshCommand'],
[/credential(..+)?.helper/, 'allowUnsafeCredentialHelper'],
[/diff(..+)?.command/, 'allowUnsafeDiffExternal'],
[/diff.external/, 'allowUnsafeDiffExternal'],
[/diff(..+)?.textconv/, 'allowUnsafeDiffTextConv'],
[/filter(..+)?.clean/, 'allowUnsafeFilter'],
[/filter(..+)?.smudge/, 'allowUnsafeFilter'],
[/gpg(..+)?.program/, 'allowUnsafeGpgProgram'],
[/init.templatedir/, 'allowUnsafeTemplateDir'],
[/merge(..+)?.driver/, 'allowUnsafeMergeDriver'],
[/mergetool(..+)?.path/, 'allowUnsafeMergeDriver'],
[/mergetool(..+)?.cmd/, 'allowUnsafeMergeDriver'],
[/protocol(..+)?.allow/, 'allowUnsafeProtocolOverride'],
[/remote(..+)?.receivepack/, 'allowUnsafePack'],
[/remote(..+)?.uploadpack/, 'allowUnsafePack'],
[/sequence.editor/, 'allowUnsafeEditor'],
] as const satisfies ReadonlyArray<readonly [RegExp, keyof UnsafeOptions]>;

function allowGeneratedConfigKeyFalsePositives(
unsafe: UnsafeOptions,
key: string,
): void {
const normalizedKey = key.toLowerCase().trim();
for (const [pattern, category] of generatedConfigKeyAllowances) {
if (pattern.test(normalizedKey)) {
unsafe[category] = true;
}
}
}

// simple-git replaces the child environment wholesale with the object passed
// to env(). Inherit the parent environment minus Git behavior variables so
// PATH-resolved git wrappers keep their indirection variables, while ambient
// Git configuration, program overrides, and network or trust redirection
// never reach the spawned client. `editor`, `pager`, `prefix`, and
// `ssh_askpass` are the non-GIT_* keys @simple-git/argv-parser (1.1.1 via
// simple-git 3.36) flags as unsafe; inheriting one would also make its
// pre-spawn check reject every task. The remaining entries block home
// locations, proxies, TLS trust overrides, and loader injection.
const blockedInheritedEnvKeys = new Set([
'home',
'userprofile',
'homedrive',
'homepath',
'http_proxy',
'https_proxy',
'all_proxy',
'no_proxy',
'ssl_cert_file',
'ssl_cert_dir',
'curl_ca_bundle',
'ld_preload',
'ld_library_path',
'dyld_insert_libraries',
'dyld_library_path',
'editor',
'pager',
'prefix',
'ssh_askpass',
]);

function isBlockedInheritedEnvKey(key: string): boolean {
const normalizedKey = key.toLowerCase().trim();
return (
normalizedKey.startsWith('git_') ||
blockedInheritedEnvKeys.has(normalizedKey)
);
}

export function createExtensionGitClient(
simpleGit: SimpleGitFactory,
{
baseDir,
signal,
networkPolicy,
networkConfig = [],
authentication,
}: ExtensionGitClientOptions,
): SimpleGit {
if (authentication) assertCredentialedHttpsSource(authentication.source);
const restrictEnvironment =
networkPolicy === 'public' || authentication !== undefined;
const hasNetworkConfig = networkConfig.length > 0;
const unsafe: UnsafeOptions = {};
if (restrictEnvironment) unsafe.allowUnsafeConfigPaths = true;
if (hasNetworkConfig) unsafe.allowUnsafeProtocolOverride = true;
const credentialConfigKey = authentication
? `http.${authentication.source}.extraHeader`
: undefined;
if (credentialConfigKey) {
unsafe.allowUnsafeConfigEnvCount = true;
// @simple-git/argv-parser scans the whole key with unanchored matchers.
// Enable only categories matched by this product-generated key.
// The child environment below still strips ambient Git variables.
// Enabled categories relax the blocklist for every task on this client,
// including argv `-c` entries. Callers must never pass user- or
// extension-derived arguments or config through a client created here.
allowGeneratedConfigKeyFalsePositives(unsafe, credentialConfigKey);
Comment thread
callmeYe marked this conversation as resolved.
Resolved
Comment thread
callmeYe marked this conversation as resolved.
Resolved
}
const git = simpleGit(baseDir, {
...(signal ? { abort: signal } : {}),
...(hasNetworkConfig ? { config: networkConfig } : {}),
...(restrictEnvironment || hasNetworkConfig ? { unsafe } : {}),
});
if (!restrictEnvironment) return git;

const environment: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value === undefined || isBlockedInheritedEnvKey(key)) continue;
Comment thread
callmeYe marked this conversation as resolved.
Resolved
environment[key] = value;
}
environment['GIT_CONFIG_NOSYSTEM'] = '1';
environment['GIT_CONFIG_GLOBAL'] = os.devNull;
if (authentication && credentialConfigKey) {
const value = Buffer.from(
`${authentication.credential.username}:${authentication.credential.password}`,
'utf8',
).toString('base64');
environment['GIT_CONFIG_COUNT'] = '1';
environment['GIT_CONFIG_KEY_0'] = credentialConfigKey;
environment['GIT_CONFIG_VALUE_0'] = `Authorization: Basic ${value}`;
}
return git.env(environment);
}
Loading
Loading