fix(a2a-server): enforce workspace trust and task isolation to prevent RCE (#28470)

This commit is contained in:
luisfelipe-alt
2026-07-21 09:38:09 -07:00
committed by GitHub
parent acae7124bd
commit c776c665b0
18 changed files with 1121 additions and 489 deletions
+3
View File
@@ -678,6 +678,7 @@ export interface ConfigParameters {
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
env?: Record<string, string>;
workspacePoliciesDir?: string;
policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean;
@@ -896,6 +897,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly useTerminalBuffer: boolean;
private readonly useRenderProcess: boolean;
private shellExecutionConfig: ShellExecutionConfig;
readonly env?: Record<string, string>;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
private readonly truncateToolOutputThreshold: number;
@@ -1119,6 +1121,7 @@ export class Config implements McpContext, AgentLoopContext {
this.checkpointing = params.checkpointing ?? false;
this.proxy = params.proxy;
this.cwd = params.cwd ?? process.cwd();
this.env = params.env;
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = params.model;
+13 -10
View File
@@ -154,6 +154,13 @@ export async function createContentGeneratorConfig(
vertexAiRouting,
};
const getEnv = (key: string) => {
if (config?.env && config.env[key] !== undefined) {
return config.env[key];
}
return process.env[key];
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now.
// Return before touching the API-key keychain: on Linux without a Secret Service
// (WSL/SSH/Docker/CI) keytar can block indefinitely on its functional probe.
@@ -165,16 +172,13 @@ export async function createContentGeneratorConfig(
}
const geminiApiKey =
apiKey ||
process.env['GEMINI_API_KEY'] ||
(await loadApiKey()) ||
undefined;
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
apiKey || getEnv('GEMINI_API_KEY') || (await loadApiKey()) || undefined;
const googleApiKey = getEnv('GOOGLE_API_KEY') || undefined;
const googleCloudProject =
process.env['GOOGLE_CLOUD_PROJECT'] ||
process.env['GOOGLE_CLOUD_PROJECT_ID'] ||
getEnv('GOOGLE_CLOUD_PROJECT') ||
getEnv('GOOGLE_CLOUD_PROJECT_ID') ||
undefined;
const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined;
const googleCloudLocation = getEnv('GOOGLE_CLOUD_LOCATION') || undefined;
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
contentGeneratorConfig.apiKey = geminiApiKey;
@@ -194,8 +198,7 @@ export async function createContentGeneratorConfig(
}
if (authType === AuthType.GATEWAY) {
contentGeneratorConfig.apiKey =
apiKey || process.env['GEMINI_API_KEY'] || '';
contentGeneratorConfig.apiKey = apiKey || getEnv('GEMINI_API_KEY') || '';
contentGeneratorConfig.vertexai = false;
return contentGeneratorConfig;
@@ -34,6 +34,10 @@ describe('CheckerRunner', () => {
beforeEach(() => {
mockContextBuilder = new ContextBuilder({} as Config);
vi.spyOn(mockContextBuilder, 'config', 'get').mockReturnValue({
env: {},
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
} as unknown as Config);
mockRegistry = new CheckerRegistry('/mock/dist');
CheckerRegistry.prototype.resolveInProcess = vi.fn();
@@ -168,6 +168,8 @@ export class CheckerRunner {
return new Promise((resolve) => {
const child = spawn(checkerPath, [], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: this.contextBuilder.config.getWorkingDir(),
env: { ...process.env, ...this.contextBuilder.config.env },
});
let stdout = '';
@@ -15,6 +15,10 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
export class ContextBuilder {
constructor(private readonly context: AgentLoopContext) {}
get config() {
return this.context.config;
}
/**
* Builds the full context object with all available data.
*/
@@ -139,6 +139,7 @@ export interface ShellExecutionConfig {
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
originalCommand?: string;
sessionId?: string;
env?: Record<string, string>;
}
/**
@@ -461,9 +462,10 @@ export class ShellExecutionService {
const spawnArgs = [...argsPrefix, finalCommand];
// 2. Prepare Environment
const sourceEnv = shellExecutionConfig.env ?? process.env;
const gitConfigKeys: string[] = [];
if (!isInteractive) {
for (const key in process.env) {
for (const key in sourceEnv) {
if (key.startsWith('GIT_CONFIG_')) {
gitConfigKeys.push(key);
}
@@ -479,7 +481,7 @@ export class ShellExecutionService {
],
};
const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig);
const sanitizedEnv = sanitizeEnvironment(sourceEnv, sanitizationConfig);
const baseEnv: Record<string, string | undefined> = {
...sanitizedEnv,
@@ -493,7 +495,7 @@ export class ShellExecutionService {
if (!isInteractive) {
// Ensure all GIT_CONFIG_* variables are preserved even if they were redacted
for (const key of gitConfigKeys) {
baseEnv[key] = process.env[key];
baseEnv[key] = sourceEnv[key];
}
const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10);
+1
View File
@@ -656,6 +656,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
this.context.config.isInteractiveShellEnabled(),
{
...shellExecutionConfig,
env: this.context.config.env,
sessionId: this.context.config?.getSessionId?.() ?? 'default',
pager: 'cat',
sanitizationConfig:
+3 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Config } from '../config/config.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
@@ -30,6 +30,7 @@ describe('Tracker Tools Integration', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-'));
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
config = new Config({
sessionId: `test-session-${Math.random().toString(36).substring(7)}`,
targetDir: tempDir,
@@ -42,6 +43,7 @@ describe('Tracker Tools Integration', () => {
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});
+4
View File
@@ -39,6 +39,10 @@ vi.mock('child_process', () => ({
spawnSync: vi.fn(() => ({ error: null, status: 0 })),
}));
vi.mock('./headless.js', () => ({
isHeadlessMode: vi.fn(() => false),
}));
const originalPlatform = process.platform;
describe('editor utils', () => {
+8
View File
@@ -9,6 +9,7 @@ import { promisify } from 'node:util';
import { once } from 'node:events';
import { debugLogger } from './debugLogger.js';
import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js';
import { isHeadlessMode } from './headless.js';
const GUI_EDITORS = [
'vscode',
@@ -404,6 +405,13 @@ export async function openDiff(
newPath: string,
editor: EditorType,
): Promise<void> {
if (isHeadlessMode()) {
debugLogger.warn(
'External editor spawning is disabled in headless/server mode.',
);
return;
}
const diffCommand = getDiffCommand(oldPath, newPath, editor);
if (!diffCommand) {
debugLogger.error('No diff tool available. Install a supported editor.');