Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/authentication-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ AWF uses a **3-container architecture** when API proxy mode is enabled:
│ Ports: │ │ http://172.30.0.30:10000 │
│ - 10000 (OpenAI proxy) │◄──────│ ✓ COPILOT_API_URL= │
│ - 10001 (Anthropic proxy) │ │ http://172.30.0.30:10002 │
│ - 10002 (Copilot proxy) │ │ GITHUB_TOKEN=ghp_...
│ - 10003 (Gemini proxy) │ │ (protected by one-shot-token)
│ - 10002 (Copilot proxy) │ │ GITHUB_TOKEN — excluded
│ - 10003 (Gemini proxy) │ │ (not present in agent env)
│ Injects auth headers: │ │ User command execution: │
│ - x-api-key: sk-ant-... │ │ claude-code, copilot, etc. │
│ - Authorization: Bearer sk-... │ └──────────────────────────────────┘
Expand Down Expand Up @@ -125,8 +125,8 @@ agent:
- COPILOT_API_URL=http://172.30.0.30:10002
- GOOGLE_GEMINI_BASE_URL=http://172.30.0.30:10003
- GEMINI_API_BASE_URL=http://172.30.0.30:10003
# GitHub token for MCP servers (protected separately)
- GITHUB_TOKEN=ghp_...
# GITHUB_TOKEN / GH_TOKEN are NOT present — excluded by the API-proxy
# exclusion set to prevent credential extraction via /proc/self/environ
networks:
awf-net:
ipv4_address: 172.30.0.20
Expand Down Expand Up @@ -307,7 +307,7 @@ The api-proxy connects to the real APIs (e.g., `api.openai.com`) over standard H

**Source:** `containers/agent/one-shot-token/`

While API keys don't exist in the agent container, other tokens (like `GITHUB_TOKEN`) do. AWF uses an `LD_PRELOAD` library to protect these:
While API keys don't exist in the agent container, other tokens may still be present. AWF uses an `LD_PRELOAD` library as defense-in-depth for any token that does reach the container:

```c
// Intercept getenv() calls
Expand All @@ -329,9 +329,9 @@ char* getenv(const char* name) {
```

**Protected tokens by default:**
- `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` (though not passed to agent when api-proxy is enabled)
- `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` (not passed to agent when api-proxy is enabled)
- `OPENAI_API_KEY`, `OPENAI_KEY`
- `GITHUB_TOKEN`, `GH_TOKEN`, `COPILOT_GITHUB_TOKEN`
- `GITHUB_TOKEN`, `GH_TOKEN`, `COPILOT_GITHUB_TOKEN` (not passed to agent when api-proxy is enabled)
- `GITHUB_API_TOKEN`, `GITHUB_PAT`, `GH_ACCESS_TOKEN`
- `CODEX_API_KEY`
- `COPILOT_PROVIDER_API_KEY` (Copilot BYOK upstream provider key)
Expand Down
148 changes: 148 additions & 0 deletions src/services/agent-environment/env-passthrough.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { passthroughHostEnvironment } from './env-passthrough';
import { WrapperConfig } from '../../types';

// Mock the logger to suppress output during tests
jest.mock('../../logger', () => ({
logger: {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
},
}));

function makeConfig(overrides: Partial<WrapperConfig> = {}): WrapperConfig {
return {
allowedDomains: [],
...overrides,
} as WrapperConfig;
}

describe('passthroughHostEnvironment', () => {
let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
// Save and clear relevant env vars before each test
savedEnv = {};
});

afterEach(() => {
// Restore env vars
for (const [key, val] of Object.entries(savedEnv)) {
if (val === undefined) {
delete process.env[key];
} else {
process.env[key] = val;
}
}
});

function withEnv(vars: Record<string, string>, fn: () => void): void {
for (const [key, val] of Object.entries(vars)) {
savedEnv[key] = process.env[key];
process.env[key] = val;
}
fn();
}

describe('alwaysForwardVars respect the exclusion set (root-cause fix)', () => {
it('does not forward GITHUB_TOKEN when it is in the exclusion set', () => {
const environment: Record<string, string> = {};
const excludedEnvVars = new Set(['GITHUB_TOKEN']);

withEnv({ GITHUB_TOKEN: 'ghs_secret' }, () => {
passthroughHostEnvironment({
config: makeConfig({ enableApiProxy: true }),
environment,
excludedEnvVars,
});
});

expect(environment).not.toHaveProperty('GITHUB_TOKEN');
});

it('does not forward GH_TOKEN when it is in the exclusion set', () => {
const environment: Record<string, string> = {};
const excludedEnvVars = new Set(['GH_TOKEN']);

withEnv({ GH_TOKEN: 'ghs_secret' }, () => {
passthroughHostEnvironment({
config: makeConfig({ enableApiProxy: true }),
environment,
excludedEnvVars,
});
});

expect(environment).not.toHaveProperty('GH_TOKEN');
});

it('does not forward GITHUB_PERSONAL_ACCESS_TOKEN when it is in the exclusion set', () => {
const environment: Record<string, string> = {};
const excludedEnvVars = new Set(['GITHUB_PERSONAL_ACCESS_TOKEN']);

withEnv({ GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_secret' }, () => {
passthroughHostEnvironment({
config: makeConfig({ enableApiProxy: true }),
environment,
excludedEnvVars,
});
});

expect(environment).not.toHaveProperty('GITHUB_PERSONAL_ACCESS_TOKEN');
});

it('forwards GITHUB_TOKEN when it is NOT in the exclusion set', () => {
const environment: Record<string, string> = {};
const excludedEnvVars = new Set<string>();

withEnv({ GITHUB_TOKEN: 'ghs_allowed' }, () => {
passthroughHostEnvironment({
config: makeConfig({ enableApiProxy: false }),
environment,
excludedEnvVars,
});
});

expect(environment).toHaveProperty('GITHUB_TOKEN', 'ghs_allowed');
});

it('forwards GH_TOKEN when it is NOT in the exclusion set', () => {
const environment: Record<string, string> = {};
const excludedEnvVars = new Set<string>();

withEnv({ GH_TOKEN: 'ghs_allowed' }, () => {
passthroughHostEnvironment({
config: makeConfig({ enableApiProxy: false }),
environment,
excludedEnvVars,
});
});

expect(environment).toHaveProperty('GH_TOKEN', 'ghs_allowed');
});

it('suppresses all three GitHub token aliases together when all are excluded', () => {
const environment: Record<string, string> = {};
const excludedEnvVars = new Set(['GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN']);

withEnv(
{
GITHUB_TOKEN: 'ghs_tok',
GH_TOKEN: 'ghs_tok2',
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_tok',
},
() => {
passthroughHostEnvironment({
config: makeConfig({ enableApiProxy: true }),
environment,
excludedEnvVars,
});
},
);

expect(environment).not.toHaveProperty('GITHUB_TOKEN');
expect(environment).not.toHaveProperty('GH_TOKEN');
expect(environment).not.toHaveProperty('GITHUB_PERSONAL_ACCESS_TOKEN');
});
});
});
2 changes: 1 addition & 1 deletion src/services/agent-environment/env-passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function passthroughHostEnvironment(params: EnvPassthroughParams): void {
] as const;

for (const v of alwaysForwardVars) {
if (process.env[v]) {
if (process.env[v] && !excludedEnvVars.has(v)) {
environment[v] = process.env[v]!;
}
}
Expand Down
36 changes: 34 additions & 2 deletions src/services/agent-environment/excluded-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ describe('buildExclusionSet', () => {
it('should exclude GOOGLE_VERTEX_BASE_URL (Vertex AI base URL)', () => {
expect(buildExclusionSet(config).has('GOOGLE_VERTEX_BASE_URL')).toBe(true);
});

it('should exclude GITHUB_TOKEN (credential isolation)', () => {
expect(buildExclusionSet(config).has('GITHUB_TOKEN')).toBe(true);
});

it('should exclude GH_TOKEN (credential isolation)', () => {
expect(buildExclusionSet(config).has('GH_TOKEN')).toBe(true);
});

it('should exclude GITHUB_PERSONAL_ACCESS_TOKEN (credential isolation)', () => {
expect(buildExclusionSet(config).has('GITHUB_PERSONAL_ACCESS_TOKEN')).toBe(true);
});
});

describe('when enableApiProxy is false', () => {
Expand All @@ -125,6 +137,18 @@ describe('buildExclusionSet', () => {
it('should NOT exclude GEMINI_API_KEY', () => {
expect(buildExclusionSet(config).has('GEMINI_API_KEY')).toBe(false);
});

it('should NOT exclude GITHUB_TOKEN', () => {
expect(buildExclusionSet(config).has('GITHUB_TOKEN')).toBe(false);
});

it('should NOT exclude GH_TOKEN', () => {
expect(buildExclusionSet(config).has('GH_TOKEN')).toBe(false);
});

it('should NOT exclude GITHUB_PERSONAL_ACCESS_TOKEN', () => {
expect(buildExclusionSet(config).has('GITHUB_PERSONAL_ACCESS_TOKEN')).toBe(false);
});
});

describe('when difcProxyHost is set (DIFC proxy security)', () => {
Expand All @@ -137,10 +161,14 @@ describe('buildExclusionSet', () => {
it('should exclude GH_TOKEN', () => {
expect(buildExclusionSet(config).has('GH_TOKEN')).toBe(true);
});

it('should exclude GITHUB_PERSONAL_ACCESS_TOKEN', () => {
expect(buildExclusionSet(config).has('GITHUB_PERSONAL_ACCESS_TOKEN')).toBe(true);
});
});

describe('when difcProxyHost is not set', () => {
const config = makeConfig({ difcProxyHost: undefined });
describe('when difcProxyHost is not set and enableApiProxy is false', () => {
const config = makeConfig({ difcProxyHost: undefined, enableApiProxy: false });

it('should NOT exclude GITHUB_TOKEN', () => {
expect(buildExclusionSet(config).has('GITHUB_TOKEN')).toBe(false);
Expand All @@ -149,6 +177,10 @@ describe('buildExclusionSet', () => {
it('should NOT exclude GH_TOKEN', () => {
expect(buildExclusionSet(config).has('GH_TOKEN')).toBe(false);
});

it('should NOT exclude GITHUB_PERSONAL_ACCESS_TOKEN', () => {
expect(buildExclusionSet(config).has('GITHUB_PERSONAL_ACCESS_TOKEN')).toBe(false);
});
});

describe('when excludeEnv is set', () => {
Expand Down
9 changes: 9 additions & 0 deletions src/services/agent-environment/excluded-vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,20 @@ export function buildExclusionSet(config: WrapperConfig): Set<string> {
excludedEnvVars.add('GEMINI_API_BASE_URL');
excludedEnvVars.add('GOOGLE_API_KEY');
excludedEnvVars.add('GOOGLE_VERTEX_BASE_URL');
// GitHub tokens are excluded when API proxy is enabled (strict mode):
// the agent must not hold live credentials that can be extracted via
// /proc/self/environ or environment inspection.
excludedEnvVars.add('GITHUB_TOKEN');
excludedEnvVars.add('GH_TOKEN');
Comment on lines +40 to +41
Comment thread
Copilot marked this conversation as resolved.
excludedEnvVars.add('GITHUB_PERSONAL_ACCESS_TOKEN');
}

if (config.difcProxyHost) {
// Redundant with enableApiProxy block above, kept for explicit documentation:
// when DIFC proxy handles GitHub auth, tokens must never reach the agent.
excludedEnvVars.add('GITHUB_TOKEN');
excludedEnvVars.add('GH_TOKEN');
excludedEnvVars.add('GITHUB_PERSONAL_ACCESS_TOKEN');
}

if (config.excludeEnv && config.excludeEnv.length > 0) {
Expand Down
6 changes: 5 additions & 1 deletion src/services/agent-environment/github-actions-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export function buildGitHubActionsEnvironment(params: GitHubActionsEnvironmentPa
}

if (config.additionalEnv) {
Object.assign(environment, config.additionalEnv);
for (const [key, value] of Object.entries(config.additionalEnv)) {
if (!excludedEnvVars.has(key)) {
environment[key] = value;
}
}
}

if (environment.NO_PROXY !== environment.no_proxy) {
Expand Down
Loading
Loading