diff --git a/services/cloud-agent-next/Dockerfile b/services/cloud-agent-next/Dockerfile index 3f38ad59d4..90b2a7b5df 100644 --- a/services/cloud-agent-next/Dockerfile +++ b/services/cloud-agent-next/Dockerfile @@ -45,6 +45,10 @@ RUN GLAB_VERSION="1.93.0" \ && dpkg -i /tmp/glab.deb \ && rm /tmp/glab.deb +COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential +RUN chmod +x /opt/kilo-cloud/kilo-git-credential \ + && ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential + # Generate locales to suppress setlocale warnings RUN apt-get update && apt-get install -y --no-install-recommends locales && \ sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ diff --git a/services/cloud-agent-next/Dockerfile.dev b/services/cloud-agent-next/Dockerfile.dev index 013a75bfab..103d32850c 100644 --- a/services/cloud-agent-next/Dockerfile.dev +++ b/services/cloud-agent-next/Dockerfile.dev @@ -55,6 +55,10 @@ RUN GLAB_VERSION="1.93.0" \ && dpkg -i /tmp/glab.deb \ && rm /tmp/glab.deb +COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential +RUN chmod +x /opt/kilo-cloud/kilo-git-credential \ + && ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential + # Install pnpm and kilocode RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION} diff --git a/services/cloud-agent-next/Dockerfile.dind b/services/cloud-agent-next/Dockerfile.dind index e7e46d80f8..0c82e64d32 100644 --- a/services/cloud-agent-next/Dockerfile.dind +++ b/services/cloud-agent-next/Dockerfile.dind @@ -52,6 +52,10 @@ RUN GLAB_VERSION="1.93.0" \ && chmod +x /usr/local/bin/glab \ && rm -rf /tmp/glab.tar.gz /tmp/bin +COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential +RUN chmod +x /opt/kilo-cloud/kilo-git-credential \ + && ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential + # Tools used by the outer sandbox. Kilo itself is still installed globally for # the existing wrapper path; the platform package bundle under /opt/kilo-agent # is intended for mounting or copying into inner dev containers. diff --git a/services/cloud-agent-next/scripts/kilo-git-credential b/services/cloud-agent-next/scripts/kilo-git-credential new file mode 100755 index 0000000000..ad37821e32 --- /dev/null +++ b/services/cloud-agent-next/scripts/kilo-git-credential @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +case "${1:-}" in +get) ;; +*) exit 0 ;; +esac + +protocol= +host= +while IFS= read -r line || [ -n "$line" ]; do + [ -z "$line" ] && break + case "$line" in + protocol=*) protocol="${line#protocol=}" ;; + host=*) host="${line#host=}" ;; + esac +done + +[ "$protocol" = https ] || exit 0 + +username= +password= + +case "$host" in +github.com) + username=x-access-token + password="${GH_TOKEN:-}" + ;; +bitbucket.org) + username=x-token-auth + password="${BITBUCKET_TOKEN:-}" + ;; +*) + gitlab_host="${GITLAB_HOST:-gitlab.com}" + gitlab_host="${gitlab_host#https://}" + gitlab_host="${gitlab_host#http://}" + gitlab_host="${gitlab_host%%/*}" + if [ "$host" = "$gitlab_host" ]; then + username=oauth2 + password="${GITLAB_TOKEN:-}" + fi + ;; +esac + +[ -n "$password" ] || exit 0 + +printf 'username=%s\npassword=%s\n' "$username" "$password" diff --git a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.test.ts b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.test.ts index d9321e6be3..99d4e53e01 100644 --- a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.test.ts +++ b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.test.ts @@ -14,6 +14,7 @@ import type { Env, SandboxInstance } from '../../types.js'; import type { CredentialContainment, SessionMetadata } from '../../persistence/session-metadata.js'; import { WrapperClient, WrapperError } from '../../kilo/wrapper-client.js'; import { WRAPPER_VERSION } from '../../shared/wrapper-version.js'; +import { SYSTEM_GIT_CONFIG_ENV } from '../../shared/runtime-environment.js'; import type { EnsureWrapperRequest } from '../protocol.js'; import { CloudflareAgentSandbox, deriveSetupEnvironment } from './cloudflare-agent-sandbox.js'; import { buildWorkspaceBackupCandidate } from '../../workspace-backup-cache.js'; @@ -251,6 +252,33 @@ describe('deriveSetupEnvironment', () => { ) ).toBeNull(); }); + + it('ignores git config keys the runtime strips before the sandbox starts', () => { + expect( + deriveSetupEnvironment( + { + envVars: { + CACHE_VARIANT: 'profile-value', + GIT_CONFIG_GLOBAL: '/tmp/evil.gitconfig', + GIT_CONFIG_KEY_2: 'credential.helper', + }, + }, + { CACHE_VARIANT: 'resolved-profile-value', ...SYSTEM_GIT_CONFIG_ENV } + ) + ).toEqual({ + variables: { CACHE_VARIANT: 'resolved-profile-value' }, + secretIdentities: {}, + }); + }); + + it('keeps the pinned git config in the cache key when a profile declares it', () => { + expect( + deriveSetupEnvironment({ envVars: { GIT_CONFIG_COUNT: '99' } }, { ...SYSTEM_GIT_CONFIG_ENV }) + ).toEqual({ + variables: { GIT_CONFIG_COUNT: '2' }, + secretIdentities: {}, + }); + }); }); describe('CloudflareAgentSandbox', () => { diff --git a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts index 4aca8edeff..a26476a5a9 100644 --- a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts +++ b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts @@ -50,6 +50,7 @@ import { import { SANDBOX_WORKSPACE_PROBE_TIMEOUT_MESSAGE } from '../../sandbox-recovery.js'; import { withTimeout } from '@kilocode/worker-utils'; import { WRAPPER_VERSION } from '../../shared/wrapper-version.js'; +import { isStrippedGitConfigEnvVar } from '../../shared/runtime-environment.js'; import { ExecutionError } from '../../execution/errors.js'; import { readProfileBundle, type SessionProfileBundle } from '../../session-profile.js'; import { @@ -157,6 +158,11 @@ export function deriveSetupEnvironment( const variables: Record = {}; for (const key of Object.keys(profile.envVars ?? {})) { if (Object.hasOwn(encryptedSecrets, key)) continue; + // The runtime environment owns git's configuration and drops these before + // the sandbox starts, so they are absent from `materializedEnvironment` by + // design. Treating that as an unresolved variable would silently disable + // workspace snapshots for every session on the profile. + if (isStrippedGitConfigEnvVar(key)) continue; if (!Object.hasOwn(materializedEnvironment, key)) return null; const value = materializedEnvironment[key]; if (value === undefined) return null; diff --git a/services/cloud-agent-next/src/kilo-git-credential.test.ts b/services/cloud-agent-next/src/kilo-git-credential.test.ts new file mode 100644 index 0000000000..bedd389147 --- /dev/null +++ b/services/cloud-agent-next/src/kilo-git-credential.test.ts @@ -0,0 +1,197 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const scriptPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../scripts/kilo-git-credential' +); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +type HelperEnv = { + GH_TOKEN?: string; + GITLAB_TOKEN?: string; + GITLAB_HOST?: string; + BITBUCKET_TOKEN?: string; +}; + +function credentialInput(protocol: string, host: string): string { + return `protocol=${protocol}\nhost=${host}\n\n`; +} + +function runHelper( + action: string | undefined, + input: string, + env: HelperEnv = {} +): { status: number | null; stdout: string; home: string } { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-git-credential-')); + tempDirs.push(home); + const result = spawnSync('sh', action === undefined ? [scriptPath] : [scriptPath, action], { + encoding: 'utf8', + input, + env: { + ...process.env, + HOME: home, + GH_TOKEN: undefined, + GITLAB_TOKEN: undefined, + GITLAB_HOST: undefined, + BITBUCKET_TOKEN: undefined, + ...env, + }, + }); + return { status: result.status, stdout: result.stdout, home }; +} + +function parseCredential(stdout: string): { + username: string | undefined; + password: string | undefined; +} { + let username: string | undefined; + let password: string | undefined; + for (const line of stdout.split('\n')) { + if (line.startsWith('username=')) { + username = line.slice('username='.length); + } else if (line.startsWith('password=')) { + password = line.slice('password='.length); + } + } + return { username, password }; +} + +function expectPassword(actual: string | undefined, expected: string): void { + if (actual !== expected) { + throw new Error('password did not match the provided token'); + } +} + +describe('kilo-git-credential', () => { + it('returns GitHub credentials including a capability token', () => { + const token = 'kgh2.cap'; + const result = runHelper('get', credentialInput('https', 'github.com'), { GH_TOKEN: token }); + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('x-access-token'); + expectPassword(parsed.password, token); + }); + + it('returns GitLab credentials for gitlab.com', () => { + const token = 'kgl2.cap'; + const result = runHelper('get', credentialInput('https', 'gitlab.com'), { + GITLAB_TOKEN: token, + }); + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('oauth2'); + expectPassword(parsed.password, token); + }); + + it('returns GitLab credentials for a custom GITLAB_HOST and ignores gitlab.com', () => { + const token = 'kgl2.custom'; + const env = { GITLAB_TOKEN: token, GITLAB_HOST: 'gitlab.example.com' }; + const custom = runHelper('get', credentialInput('https', 'gitlab.example.com'), env); + expect(custom.status).toBe(0); + const parsed = parseCredential(custom.stdout); + expect(parsed.username).toBe('oauth2'); + expectPassword(parsed.password, token); + + const defaultHost = runHelper('get', credentialInput('https', 'gitlab.com'), env); + expect(defaultHost.status).toBe(0); + expect(defaultHost.stdout).toBe(''); + }); + + it('matches GITLAB_HOST and requested host including their port', () => { + const token = 'kgl2.port'; + const env = { GITLAB_TOKEN: token, GITLAB_HOST: 'gitlab.example.com:8443' }; + const requestedWithPort = runHelper( + 'get', + credentialInput('https', 'gitlab.example.com:8443'), + env + ); + expect(requestedWithPort.status).toBe(0); + const parsedRequested = parseCredential(requestedWithPort.stdout); + expect(parsedRequested.username).toBe('oauth2'); + expectPassword(parsedRequested.password, token); + + const requestedWithoutPort = runHelper( + 'get', + credentialInput('https', 'gitlab.example.com'), + env + ); + expect(requestedWithoutPort.status).toBe(0); + expect(requestedWithoutPort.stdout).toBe(''); + }); + + it('does not return a GitHub token for a different port', () => { + const result = runHelper('get', credentialInput('https', 'github.com:8443'), { + GH_TOKEN: 'kgh2.unused', + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('accepts GITLAB_HOST with a scheme and path', () => { + const token = 'kgl2.scheme'; + const result = runHelper('get', credentialInput('https', 'gitlab.example.com'), { + GITLAB_TOKEN: token, + GITLAB_HOST: 'https://gitlab.example.com/gitlab', + }); + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('oauth2'); + expectPassword(parsed.password, token); + }); + + it('returns Bitbucket credentials', () => { + const token = 'kbb1.cap'; + const result = runHelper('get', credentialInput('https', 'bitbucket.org'), { + BITBUCKET_TOKEN: token, + }); + expect(result.status).toBe(0); + const parsed = parseCredential(result.stdout); + expect(parsed.username).toBe('x-token-auth'); + expectPassword(parsed.password, token); + }); + + it('prints nothing for an unmatched host', () => { + const result = runHelper('get', credentialInput('https', 'example.com'), { + GH_TOKEN: 'kgh2.unused', + GITLAB_TOKEN: 'kgl2.unused', + BITBUCKET_TOKEN: 'kbb1.unused', + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('prints nothing when the matching https token is missing', () => { + const result = runHelper('get', credentialInput('https', 'github.com')); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('prints nothing for http', () => { + const result = runHelper('get', credentialInput('http', 'github.com'), { + GH_TOKEN: 'kgh2.unused', + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it.each(['store', 'erase', 'unknown'] as const)('%s exits 0 without writing files', action => { + const result = runHelper(action, credentialInput('https', 'github.com'), { + GH_TOKEN: 'kgh2.unused', + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(fs.existsSync(path.join(result.home, '.git-credentials'))).toBe(false); + expect(fs.readdirSync(result.home)).toEqual([]); + }); +}); diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index 3f4dbb3f50..4d30880733 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -125,6 +125,53 @@ describe('SessionService.buildRuntimeEnv', () => { expect(runtimeEnv.HOME).toBe('/home/agent_test'); expect(runtimeEnv.SESSION_HOME).toBe('/home/agent_test'); expect(runtimeEnv[PNPM_STORE_ENV_VAR]).toBe(PNPM_STORE_DIR); + expect(runtimeEnv.GIT_CONFIG_COUNT).toBe('2'); + expect(runtimeEnv.GIT_CONFIG_KEY_0).toBe('credential.helper'); + expect(runtimeEnv.GIT_CONFIG_VALUE_0).toBe('/opt/kilo-cloud/kilo-git-credential'); + expect(runtimeEnv.GIT_CONFIG_KEY_1).toBe('credential.useHttpPath'); + expect(runtimeEnv.GIT_CONFIG_VALUE_1).toBe('false'); + expect(runtimeEnv.GIT_TERMINAL_PROMPT).toBe('0'); + expect(runtimeEnv.GIT_CONFIG_NOSYSTEM).toBeUndefined(); + expect(runtimeEnv.GIT_CONFIG_GLOBAL).toBeUndefined(); + expect(runtimeEnv.GIT_OPTIONAL_LOCKS).toBeUndefined(); + }); + + it('wins over a profile that tries to override the git credential helper', () => { + const service = new SessionService(); + const context = service.buildContext({ + sandboxId: 'usr-test', + userId: 'user_test', + sessionId: 'agent_test', + envVars: { + GIT_CONFIG_COUNT: '99', + GIT_CONFIG_KEY_0: 'user.email', + GIT_CONFIG_VALUE_0: 'attacker@example.com', + GIT_CONFIG_KEY_1: 'credential.helper', + GIT_CONFIG_VALUE_1: '/tmp/evil-helper', + GIT_CONFIG_KEY_2: 'credential.helper', + GIT_CONFIG_VALUE_2: '/tmp/second-evil-helper', + GIT_CONFIG_GLOBAL: '/tmp/evil.gitconfig', + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '1', + }, + }); + + const runtimeEnv = service.buildRuntimeEnv({ + context, + env: createEnv(), + kiloCapability: 'kilo-token', + }); + + expect(runtimeEnv.GIT_CONFIG_COUNT).toBe('2'); + expect(runtimeEnv.GIT_CONFIG_KEY_0).toBe('credential.helper'); + expect(runtimeEnv.GIT_CONFIG_VALUE_0).toBe('/opt/kilo-cloud/kilo-git-credential'); + expect(runtimeEnv.GIT_CONFIG_KEY_1).toBe('credential.useHttpPath'); + expect(runtimeEnv.GIT_CONFIG_VALUE_1).toBe('false'); + expect(runtimeEnv.GIT_TERMINAL_PROMPT).toBe('0'); + expect(runtimeEnv.GIT_CONFIG_KEY_2).toBeUndefined(); + expect(runtimeEnv.GIT_CONFIG_VALUE_2).toBeUndefined(); + expect(runtimeEnv.GIT_CONFIG_GLOBAL).toBeUndefined(); + expect(runtimeEnv.GIT_CONFIG_NOSYSTEM).toBeUndefined(); }); }); @@ -3270,14 +3317,17 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { `bb comments create 42 --input - < ${inputPath}` ) ).toBe('allow'); + expect(result.readyRequest.materialized.env).toMatchObject({ + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '/opt/kilo-cloud/kilo-git-credential', + GIT_CONFIG_KEY_1: 'credential.useHttpPath', + GIT_CONFIG_VALUE_1: 'false', + GIT_TERMINAL_PROMPT: '0', + }); for (const key of [ 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_GLOBAL', - 'GIT_CONFIG_COUNT', - 'GIT_CONFIG_KEY_0', - 'GIT_CONFIG_VALUE_0', - 'GIT_CONFIG_KEY_1', - 'GIT_CONFIG_VALUE_1', 'GIT_CONFIG_KEY_2', 'GIT_CONFIG_VALUE_2', 'GIT_OPTIONAL_LOCKS', diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index e9aae83917..923b7a3502 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -82,7 +82,12 @@ import { type WrapperWorkspaceReady, } from './shared/wrapper-bootstrap.js'; import { buildCloudAgentRules } from './shared/cloud-agent-rules.js'; -import { PNPM_STORE_DIR, PNPM_STORE_ENV_VAR } from './shared/runtime-environment.js'; +import { + isStrippedGitConfigEnvVar, + PNPM_STORE_DIR, + PNPM_STORE_ENV_VAR, + SYSTEM_GIT_CONFIG_ENV, +} from './shared/runtime-environment.js'; import type { FencedLegacyExecutionRequest, FencedWrapperDispatchRequest, @@ -1363,6 +1368,8 @@ export class SessionService { SESSION_ID: sessionId, SESSION_HOME: sessionHome, [PNPM_STORE_ENV_VAR]: PNPM_STORE_DIR, + ...SYSTEM_GIT_CONFIG_ENV, + GIT_TERMINAL_PROMPT: '0', // Opaque Kilo capability — redeemed for the real credential at the outbound interceptor KILOCODE_TOKEN: kiloCapability, // Backend auth surface (session restore/import). @@ -1374,6 +1381,12 @@ export class SessionService { KILOCODE_FEATURE: createdOnPlatform ?? 'cloud-agent', }; + for (const key of Object.keys(envVars)) { + if (isStrippedGitConfigEnvVar(key)) { + delete envVars[key]; + } + } + const providerOptions: Record = { apiKey: kiloCapability, kilocodeToken: kiloCapability, @@ -2700,9 +2713,14 @@ export class SessionService { * * GitHub App installation tokens expire after ~1h, and server-resolved GitLab * credentials can rotate independently of a warm workspace. The URL-embedded - * credentials from the original clone go stale quickly. `GH_TOKEN` / - * `GITLAB_TOKEN` env vars don't rescue `git` itself (they only affect the - * provider CLIs / GitLab HTTP integrations), so we rewrite `origin` whenever + * credentials from the original clone go stale quickly. + * + * The pinned `credential.helper` (`SYSTEM_GIT_CONFIG_ENV`) does serve `git` + * itself from `GH_TOKEN` / `GITLAB_TOKEN`, but it only rescues a remote whose + * URL lacks a password: when the URL carries both a username and a password, + * git sends that pair and never issues a `get` to the helper (verified against + * git 2.50 — on the 401 it only calls `erase`). A stale embedded pair + * therefore fails the fetch outright, so we still rewrite `origin` whenever * the token is resolved by us. */ private async refreshGitRemoteToken( diff --git a/services/cloud-agent-next/src/shared/runtime-environment.ts b/services/cloud-agent-next/src/shared/runtime-environment.ts index d6d587fcc7..04cf471324 100644 --- a/services/cloud-agent-next/src/shared/runtime-environment.ts +++ b/services/cloud-agent-next/src/shared/runtime-environment.ts @@ -1,2 +1,40 @@ export const PNPM_STORE_DIR = '/var/cache/kilo/pnpm-store'; export const PNPM_STORE_ENV_VAR = 'pnpm_config_store_dir'; + +/** + * Git configuration the platform pins for every sandbox: a read-only credential + * helper that redeems Kilo capabilities at the outbound interceptor. + * + * Spread *after* user-supplied env vars in the runtime environment so these + * values always win. + * + * `GIT_CONFIG_VALUE_0` must match the `COPY` destination in `Dockerfile`, + * `Dockerfile.dind`, and `Dockerfile.dev`; nothing at build or test time ties + * the two together, and a mismatch only surfaces as a runtime helper warning. + */ +export const SYSTEM_GIT_CONFIG_ENV = { + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '/opt/kilo-cloud/kilo-git-credential', + GIT_CONFIG_KEY_1: 'credential.useHttpPath', + GIT_CONFIG_VALUE_1: 'false', +} as const; + +const SYSTEM_GIT_CONFIG_KEYS: ReadonlySet = new Set(Object.keys(SYSTEM_GIT_CONFIG_ENV)); + +/** + * True for a `GIT_CONFIG_*` env var that never reaches the sandbox because the + * platform owns git's configuration. + * + * `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` point git at a config file whose + * `credential.helper` entries are read *before* the enumerated slots above, and + * `GIT_CONFIG_PARAMETERS` is applied last, where an empty `credential.helper=` + * resets every helper ahead of it. Either would displace the pinned helper. The + * slots the platform sets itself are kept — `SYSTEM_GIT_CONFIG_ENV` + * is spread last, so a user-supplied `GIT_CONFIG_KEY_` beyond + * `GIT_CONFIG_COUNT` is inert anyway; dropping it keeps the sandbox environment + * a faithful reflection of the profile it was built from. + */ +export function isStrippedGitConfigEnvVar(key: string): boolean { + return key.startsWith('GIT_CONFIG_') && !SYSTEM_GIT_CONFIG_KEYS.has(key); +}