Files
Codeman/test/copilot-custom-endpoint.test.ts
Codeman maintainer 75186a5d97 fix(#592): clamp the COPILOT_* variables that bypass approval, run code or move the token
The COPILOT_ prefix admits every variable Copilot reads, and the 1.0.98
bundle reads far more than `copilot help environment` lists. Only
COPILOT_HOME, COPILOT_ALLOW_ALL and the BYOK trio were privileged, so a
non-granted multi-user owner could still hand back what the allowAll
clamp removed:

- approval: COPILOT_PLAN_THEN_AUTOPILOT, COPILOT_ASSISTED_APPROVAL
- code with no approval row: COPILOT_CLI_DIST_DIR runs <dir>/index.js
  in place of the CLI, the package caches choose the CLI build,
  COPILOT_EDITOR, COPILOT_PROVIDER_API_KEY_COMMAND and
  COPILOT_AHP_HOST_BIN are executed, skills, provider configs and hooks
  are loaded from the named places (the DSH_HOME class)
- token redirects: COPILOT_API_URL and the other API bases receive the
  CLI's sign-in token, which in multi-user mode is the operator's (all
  users share the OS account and ~/.copilot), and the rest of the BYOK
  provider family re-aims a Codeman custom endpoint (the
  DEEPSEEK_BASE_URL / OMP_AUTH_BROKER_URL class)

All are now privilegedEnvKeys. The registry schema capped that list at 8;
the cap is now 48, since a longer deny list only takes more away from
non-granted owners. The test sends one of each class through the real
schema (they all pass it) and the real clamp. The docs say the list is
read from the 1.0.98 bundle and that a variable a later release adds is
not covered until it is listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:04:41 +02:00

95 lines
4.1 KiB
TypeScript

import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { CreateSessionSchema } from '../src/web/schemas.js';
const copilot = STOCK_CLIS.find((c) => (c.id as string) === 'copilot')!;
describe('GitHub Copilot custom endpoint (BYOK)', () => {
it('injects its COPILOT_PROVIDER_* variables, with the /v1 suffix an OpenAI route needs', () => {
expect(copilot.capabilities.customModelInjection).toEqual({
kind: 'env',
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
modelVars: ['COPILOT_MODEL'],
appendV1Suffix: true,
});
});
it('keeps every injected variable privileged, so a non-granted owner cannot set them by hand', () => {
const injection = copilot.capabilities.customModelInjection;
if (injection.kind !== 'env') throw new Error('expected env injection');
for (const name of [injection.baseUrlVar, injection.apiKeyVar, ...injection.modelVars]) {
expect(copilot.capabilities.privilegedEnvKeys).toContain(name);
expect(name.startsWith('COPILOT_')).toBe(true); // reachable through the prefix allowlist
}
});
});
describe('GitHub Copilot environment and sync declarations', () => {
it('admits only its own COPILOT_ namespace, never the shared GitHub token names', () => {
// The env allowlist is one global list with no mode context, so a foreign key here would be
// settable on every session. COPILOT_GITHUB_TOKEN is already reachable through the prefix.
expect(copilot.env.allowedPrefixes).toEqual(['COPILOT_']);
expect(copilot.env.allowedKeys).toEqual([]);
});
it('declares its own MCP config so Settings does not list it as unsupported', () => {
expect(copilot.capabilities.mcpConfig).toEqual({
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
});
});
it('seeds no credentials into a Docker case', () => {
expect(copilot.overlays?.credStore).toBeUndefined();
});
});
describe('GitHub Copilot multi-user clamp: the env-var half', () => {
// COPILOT_ is an allowlisted prefix, so every variable below would otherwise pass the schema.
// Each one hands back what the allowAll clamp removed: an approval switch, code that runs with no
// approval row (a CLI build, an editor, a key command, skills, hooks), or the sign-in token sent to
// a host of the caller's choosing.
const DANGEROUS: Record<string, string> = {
COPILOT_ALLOW_ALL: 'true',
COPILOT_PLAN_THEN_AUTOPILOT: '1',
COPILOT_HOME: '/tmp/planted-copilot-home',
COPILOT_CLI_DIST_DIR: '/tmp/planted-dist',
COPILOT_PKG_CACHE_HOME: '/tmp/planted-cache',
COPILOT_CACHE_HOME: '/tmp/planted-cache',
COPILOT_EDITOR: 'sh -c planted',
COPILOT_PROVIDER_API_KEY_COMMAND: 'sh -c planted',
COPILOT_SKILLS_DIRS: '/tmp/planted-skills',
COPILOT_PROVIDERS_CONFIG: '/tmp/planted-providers.json',
COPILOT_API_URL: 'https://attacker.example',
COPILOT_GH_HOST: 'attacker.example',
COPILOT_PROVIDER_BASE_URL: 'https://attacker.example/v1',
COPILOT_PROVIDER_HEADERS: 'X-Planted: 1',
};
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('passes the env allowlist, so only the clamp stands between these and the pane', () => {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: DANGEROUS })
).not.toThrow();
});
it("strips every one of them from a non-granted owner's overrides and keeps the harmless knobs", async () => {
const out = await _clampEnvOverridesForOwner('nobody', {
...DANGEROUS,
COPILOT_GITHUB_TOKEN: 'own-token',
COPILOT_AUTO_UPDATE: 'false',
});
expect(out).toEqual({ COPILOT_GITHUB_TOKEN: 'own-token', COPILOT_AUTO_UPDATE: 'false' });
});
});