Skip to content

Commit 2ad6d7d

Browse files
committed
feat: broker GitHub CLI authentication
1 parent 25f3841 commit 2ad6d7d

4 files changed

Lines changed: 129 additions & 18 deletions

File tree

‎packages/code/README.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -180,14 +180,17 @@ verification are implemented; use macOS, Linux, or WSL2. This also applies to Gi
180180
private keys.
181181

182182
Git receives authentication through process-scoped `GIT_CONFIG_*` variables.
183-
The same isolated config supplies the standard Git LFS filters; hosts using LFS
184-
must install `git-lfs`, and checkout fails instead of silently leaving pointer
185-
files when it is unavailable.
186-
SRT replaces only the bearer-token portion with a sentinel inside the sandbox
187-
and substitutes the real value in its host proxy only for `github.com` HTTPS
188-
traffic. TLS termination is enabled for that substitution. The worker restores
189-
the parent environment immediately after constructing the sandbox command; it
190-
never writes credentials into the repository, a remote URL, or Git config.
183+
When the GitHub CLI is installed, `gh api`, pull-request, issue, and workflow
184+
commands receive the same installation scope through `GH_TOKEN` (or
185+
`GH_ENTERPRISE_TOKEN` for GHES). The same isolated Git config supplies the
186+
standard Git LFS filters; hosts using LFS must install `git-lfs`, and checkout
187+
fails instead of silently leaving pointer files when it is unavailable.
188+
SRT replaces each real credential with a sentinel inside the sandbox and
189+
substitutes the real value in its host proxy only for the corresponding Git or
190+
GitHub API host. TLS termination is enabled for that substitution. The worker
191+
restores the parent environment immediately after constructing the sandbox
192+
command; it never writes credentials into the repository, a remote URL, Git
193+
config, or the GitHub CLI credential store.
191194
GitHub's required domains are added to the command egress allowlist only when
192195
authentication is configured. The worker identity, GitHub App key path, token
193196
source variables, and mutation-quarantine record remain denied to sandboxed

‎packages/code/src/cli.ts‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ import type { NativeProcessSandboxOptions } from './native-process.js';
3838
import type { LocalWorkspaceConfig } from './workspace.js';
3939
import {
4040
GITHUB_ALLOWED_DOMAINS,
41-
GITHUB_CREDENTIAL_ENV_NAME,
4241
GitHubAppCredentialProvider,
42+
gitHubCommandCredentialEnvironment,
43+
gitHubMaskedCredentialVariables,
4344
StaticGitHubCredentialProvider,
4445
gitHubAuthenticationPolicyIdentity,
45-
gitHubCredentialEnvironment,
4646
normalizeGitHubHost,
4747
wrapGitHubCredentialCommand,
4848
} from './github.js';
@@ -783,16 +783,11 @@ async function run(
783783
...(github.provider
784784
? {
785785
maskedEnvironment: {
786-
variables: [
787-
{
788-
name: GITHUB_CREDENTIAL_ENV_NAME,
789-
extract: '^(.+)$',
790-
injectHosts: [github.host],
791-
},
792-
],
786+
variables: gitHubMaskedCredentialVariables(github.host),
793787
async resolve(signal?: AbortSignal) {
794-
return gitHubCredentialEnvironment(
788+
return gitHubCommandCredentialEnvironment(
795789
await github.provider!.getCredential(signal),
790+
github.host,
796791
);
797792
},
798793
wrapCommand(command: string, platform: NodeJS.Platform) {

‎packages/code/src/github.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
GitHubAppCredentialProvider,
1818
StaticGitHubCredentialProvider,
1919
gitHubAuthenticationPolicyIdentity,
20+
gitHubCliTokenEnvironmentName,
21+
gitHubCommandCredentialEnvironment,
22+
gitHubMaskedCredentialVariables,
2023
GITHUB_CREDENTIAL_ENV_NAME,
2124
gitHubCredentialEnvironment,
2225
normalizeGitHubHost,
@@ -150,6 +153,65 @@ test('builds process-scoped Git HTTPS authorization without embedding credential
150153
assert.ok(!encodedCredential.includes('github_pat_'));
151154
});
152155

156+
test('adds a GitHub CLI token only to the command-sandbox credential bundle', async () => {
157+
const provider = new StaticGitHubCredentialProvider(
158+
'github_pat_abcdefghijklmnopqrstuvwxyz',
159+
);
160+
const credential = await provider.getCredential();
161+
assert.deepEqual(gitHubCommandCredentialEnvironment(credential), {
162+
[GITHUB_CREDENTIAL_ENV_NAME]: Buffer.from(
163+
'x-access-token:github_pat_abcdefghijklmnopqrstuvwxyz',
164+
'utf8',
165+
).toString('base64'),
166+
GH_TOKEN: 'github_pat_abcdefghijklmnopqrstuvwxyz',
167+
});
168+
assert.deepEqual(
169+
gitHubCommandCredentialEnvironment(credential, 'github.example.test'),
170+
{
171+
[GITHUB_CREDENTIAL_ENV_NAME]: Buffer.from(
172+
'x-access-token:github_pat_abcdefghijklmnopqrstuvwxyz',
173+
'utf8',
174+
).toString('base64'),
175+
GH_ENTERPRISE_TOKEN: 'github_pat_abcdefghijklmnopqrstuvwxyz',
176+
},
177+
);
178+
});
179+
180+
test('selects the GitHub CLI token variable for public and enterprise hosts', () => {
181+
assert.equal(gitHubCliTokenEnvironmentName('github.com'), 'GH_TOKEN');
182+
assert.equal(
183+
gitHubCliTokenEnvironmentName('github.example.test'),
184+
'GH_ENTERPRISE_TOKEN',
185+
);
186+
});
187+
188+
test('restricts Git and GitHub CLI credential substitution to their respective hosts', () => {
189+
assert.deepEqual(gitHubMaskedCredentialVariables('github.com'), [
190+
{
191+
name: GITHUB_CREDENTIAL_ENV_NAME,
192+
extract: '^(.+)$',
193+
injectHosts: ['github.com'],
194+
},
195+
{
196+
name: 'GH_TOKEN',
197+
extract: '^(.+)$',
198+
injectHosts: ['api.github.com'],
199+
},
200+
]);
201+
assert.deepEqual(gitHubMaskedCredentialVariables('github.example.test'), [
202+
{
203+
name: GITHUB_CREDENTIAL_ENV_NAME,
204+
extract: '^(.+)$',
205+
injectHosts: ['github.example.test'],
206+
},
207+
{
208+
name: 'GH_ENTERPRISE_TOKEN',
209+
extract: '^(.+)$',
210+
injectHosts: ['github.example.test'],
211+
},
212+
]);
213+
});
214+
153215
test('composes the masked credential with SRT Git configuration inside the sandbox', () => {
154216
const wrapped = wrapGitHubCredentialCommand(
155217
'git push',
@@ -160,10 +222,21 @@ test('composes the masked credential with SRT Git configuration inside the sandb
160222
assert.match(wrapped, /http\.https:\/\/github\.com\/\.extraheader/);
161223
assert.match(wrapped, /\$\{LIBRECHAT_CODE_GITHUB_AUTHORIZATION\}/);
162224
assert.match(wrapped, /unset LIBRECHAT_CODE_GITHUB_AUTHORIZATION/);
225+
assert.doesNotMatch(wrapped, /unset GH_TOKEN/);
163226
assert.equal(wrapped.match(/Authorization: Basic/g)?.length, 1);
164227
assert.ok(!wrapped.includes('github_pat_'));
165228
});
166229

230+
test('targets GitHub CLI at an enterprise host without exposing its token', () => {
231+
const wrapped = wrapGitHubCredentialCommand(
232+
'gh pr create',
233+
'github.example.test',
234+
'linux',
235+
);
236+
assert.match(wrapped, /GH_HOST=github\.example\.test/);
237+
assert.doesNotMatch(wrapped, /GH_ENTERPRISE_TOKEN=/);
238+
});
239+
167240
test('rejects an insecure GitHub App API endpoint before reading the private key', () => {
168241
assert.throws(
169242
() =>

‎packages/code/src/github.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,43 @@ export function gitHubCredentialEnvironment(
204204
};
205205
}
206206

207+
export function gitHubCommandCredentialEnvironment(
208+
credential: GitHubCredential,
209+
host = 'github.com',
210+
): Record<string, string> {
211+
return {
212+
...gitHubCredentialEnvironment(credential),
213+
[gitHubCliTokenEnvironmentName(host)]: credential.value,
214+
};
215+
}
216+
217+
export function gitHubCliTokenEnvironmentName(host: string): string {
218+
return host === 'github.com' ? 'GH_TOKEN' : 'GH_ENTERPRISE_TOKEN';
219+
}
220+
221+
export function gitHubApiHost(host: string): string {
222+
return host === 'github.com' ? 'api.github.com' : host;
223+
}
224+
225+
export function gitHubMaskedCredentialVariables(host: string): Array<{
226+
name: string;
227+
injectHosts: string[];
228+
extract: string;
229+
}> {
230+
return [
231+
{
232+
name: GITHUB_CREDENTIAL_ENV_NAME,
233+
extract: '^(.+)$',
234+
injectHosts: [host],
235+
},
236+
{
237+
name: gitHubCliTokenEnvironmentName(host),
238+
extract: '^(.+)$',
239+
injectHosts: [gitHubApiHost(host)],
240+
},
241+
];
242+
}
243+
207244
export function gitHubAuthenticationPolicyIdentity(options: {
208245
mode?: 'app' | 'token';
209246
host: string;
@@ -249,17 +286,20 @@ export function wrapGitHubCredentialCommand(
249286
platform: NodeJS.Platform = process.platform,
250287
): string {
251288
const key = `http.https://${host}/.extraheader`;
289+
const cliHost = host === 'github.com' ? undefined : host;
252290
if (platform === 'win32') {
253291
return [
254292
'set "GIT_CONFIG_GLOBAL=NUL"',
255293
'set "GIT_CONFIG_NOSYSTEM=1"',
294+
...(cliHost ? [`set "GH_HOST=${cliHost}"`] : []),
256295
`set "GIT_CONFIG_PARAMETERS='http.proxyAuthMethod=basic' '${key}=Authorization: Basic %${GITHUB_CREDENTIAL_ENV_NAME}%'"`,
257296
`set "${GITHUB_CREDENTIAL_ENV_NAME}="`,
258297
command,
259298
].join(' && ');
260299
}
261300
return [
262301
'export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1',
302+
...(cliHost ? [`export GH_HOST=${cliHost}`] : []),
263303
`export GIT_CONFIG_PARAMETERS="'http.proxyAuthMethod=basic' '${key}=Authorization: Basic \${${GITHUB_CREDENTIAL_ENV_NAME}}'"`,
264304
`unset ${GITHUB_CREDENTIAL_ENV_NAME}`,
265305
command,

0 commit comments

Comments
 (0)