Skip to content

Commit 4d99c73

Browse files
fix(a2a-server): guard deep-merge against null settings and clone nested values
Address review feedback on #28094: - Normalize parsed user/workspace settings to `{}` when a file is empty or contains `null`/a primitive (JSON.parse("null") === null). This prevents a server crash both in the deep merge (Object.entries(null)) and at the earlier folderTrust / later policyPaths property accesses. - During the merge, deep-clone a nested source object when the target value is not a plain object, instead of assigning it by reference. This stops the merged result from sharing references with the workspace settings object, matching the behavior in cli/src/utils/deepMerge.ts. Adds unit tests for deepMergeSettings (null-safety, reference cloning) and loadSettings (null settings files do not crash). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c1a5c35 commit 4d99c73

2 files changed

Lines changed: 106 additions & 7 deletions

File tree

packages/a2a-server/src/config/settings.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
88
import * as fs from 'node:fs';
99
import * as path from 'node:path';
1010
import * as os from 'node:os';
11-
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
11+
import {
12+
deepMergeSettings,
13+
loadSettings,
14+
USER_SETTINGS_PATH,
15+
} from './settings.js';
1216
import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
1317

1418
const mocks = vi.hoisted(() => {
@@ -215,6 +219,35 @@ describe('loadSettings', () => {
215219
expect(result.showMemoryUsage).toBe(true);
216220
});
217221

222+
it('does not crash when the user settings file contains null', () => {
223+
// JSON.parse("null") returns null, which would otherwise crash downstream
224+
// property access and the deep merge.
225+
fs.writeFileSync(USER_SETTINGS_PATH, 'null');
226+
227+
expect(() => loadSettings(mockWorkspaceDir)).not.toThrow();
228+
const result = loadSettings(mockWorkspaceDir);
229+
expect(result).toEqual({
230+
policyPaths: undefined,
231+
adminPolicyPaths: undefined,
232+
});
233+
});
234+
235+
it('does not crash when the workspace settings file contains null', () => {
236+
fs.writeFileSync(
237+
USER_SETTINGS_PATH,
238+
JSON.stringify({ showMemoryUsage: true }),
239+
);
240+
const workspaceSettingsPath = path.join(
241+
mockGeminiWorkspaceDir,
242+
'settings.json',
243+
);
244+
fs.writeFileSync(workspaceSettingsPath, 'null');
245+
246+
expect(() => loadSettings(mockWorkspaceDir, true)).not.toThrow();
247+
const result = loadSettings(mockWorkspaceDir, true);
248+
expect(result.showMemoryUsage).toBe(true);
249+
});
250+
218251
describe('security', () => {
219252
it('should NOT load workspace settings if workspace is NOT trusted', () => {
220253
const userSettings = { showMemoryUsage: false };
@@ -289,3 +322,42 @@ describe('loadSettings', () => {
289322
});
290323
});
291324
});
325+
326+
describe('deepMergeSettings', () => {
327+
it('does not throw when either side is null', () => {
328+
const nullValue = null as unknown as Record<string, unknown>;
329+
expect(() => deepMergeSettings(nullValue, { a: 1 })).not.toThrow();
330+
expect(() => deepMergeSettings({ a: 1 }, nullValue)).not.toThrow();
331+
expect(() => deepMergeSettings(nullValue, nullValue)).not.toThrow();
332+
333+
expect(deepMergeSettings(nullValue, { a: 1 })).toEqual({ a: 1 });
334+
expect(deepMergeSettings({ a: 1 }, nullValue)).toEqual({ a: 1 });
335+
expect(deepMergeSettings(nullValue, nullValue)).toEqual({});
336+
});
337+
338+
it('deep-clones nested source objects so the result does not share references', () => {
339+
// `target` has no `nested` key, so `source.nested` would previously be
340+
// assigned by reference, letting later mutations of the result leak back
341+
// into the source object.
342+
const source = { nested: { keep: true } };
343+
const result = deepMergeSettings<Record<string, unknown>>({}, source);
344+
345+
expect(result['nested']).toEqual({ keep: true });
346+
expect(result['nested']).not.toBe(source.nested);
347+
348+
// Mutating the merged result must not affect the original source object.
349+
(result['nested'] as Record<string, unknown>)['keep'] = false;
350+
expect(source.nested.keep).toBe(true);
351+
});
352+
353+
it('deep-clones nested objects even when the target value is a primitive', () => {
354+
const source = { section: { value: 1 } };
355+
const result = deepMergeSettings<Record<string, unknown>>(
356+
{ section: 'not-an-object' },
357+
source,
358+
);
359+
360+
expect(result['section']).toEqual({ value: 1 });
361+
expect(result['section']).not.toBe(source.section);
362+
});
363+
});

packages/a2a-server/src/config/settings.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ export function loadSettings(
8787
const parsedUserSettings = JSON.parse(
8888
stripJsonComments(userContent),
8989
) as Settings;
90-
userSettings = resolveEnvVarsInObject(parsedUserSettings);
90+
userSettings = normalizeSettings(
91+
resolveEnvVarsInObject(parsedUserSettings),
92+
);
9193
}
9294
} catch (error: unknown) {
9395
settingsErrors.push({
@@ -121,7 +123,9 @@ export function loadSettings(
121123
const parsedWorkspaceSettings = JSON.parse(
122124
stripJsonComments(projectContent),
123125
) as Settings;
124-
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
126+
workspaceSettings = normalizeSettings(
127+
resolveEnvVarsInObject(parsedWorkspaceSettings),
128+
);
125129
}
126130
} catch (error: unknown) {
127131
settingsErrors.push({
@@ -207,18 +211,35 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
207211
return typeof value === 'object' && value !== null && !Array.isArray(value);
208212
}
209213

214+
/**
215+
* Normalizes a parsed settings value into a plain `Settings` object. An empty
216+
* settings file or one containing `null` or a primitive parses to a non-object
217+
* (e.g. `JSON.parse("null")` returns `null`); returning `{}` in those cases
218+
* keeps downstream property access and merging safe instead of crashing.
219+
*/
220+
function normalizeSettings(value: unknown): Settings {
221+
return isPlainObject(value) ? (value as Settings) : {};
222+
}
223+
210224
/**
211225
* Deeply merges two settings objects. Nested plain objects are merged
212226
* recursively so that the `source` settings override only the specific keys
213227
* they define, instead of replacing an entire nested section. Arrays and
214228
* primitive values in `source` replace those in `target`, and `undefined`
215229
* values in `source` are ignored so they never clobber an existing value.
230+
*
231+
* Exported for testing.
216232
*/
217-
function deepMergeSettings<T extends object>(target: T, source: T): T {
218-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
219-
const output = { ...target } as Record<string, unknown>;
233+
export function deepMergeSettings<T extends object>(target: T, source: T): T {
234+
// Defensively default to empty objects when either side is not a plain
235+
// object. An empty or `null` settings file parses to `null`
236+
// (`JSON.parse("null")`), and calling `Object.entries(null)` would throw and
237+
// crash the server.
238+
const t = isPlainObject(target) ? target : {};
239+
const s = isPlainObject(source) ? source : {};
240+
const output: Record<string, unknown> = { ...t };
220241

221-
for (const [key, sourceValue] of Object.entries(source)) {
242+
for (const [key, sourceValue] of Object.entries(s)) {
222243
// Skip dangerous keys that JSON.parse can create as own properties, to
223244
// prevent prototype pollution.
224245
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
@@ -230,6 +251,12 @@ function deepMergeSettings<T extends object>(target: T, source: T): T {
230251
const targetValue = output[key];
231252
if (isPlainObject(targetValue) && isPlainObject(sourceValue)) {
232253
output[key] = deepMergeSettings(targetValue, sourceValue);
254+
} else if (isPlainObject(sourceValue)) {
255+
// The source value is a nested object but the target value is not, so
256+
// recursively clone it. Assigning `sourceValue` directly would make the
257+
// merged result share references with the original `source` object,
258+
// allowing later mutations to leak back into it.
259+
output[key] = deepMergeSettings({}, sourceValue);
233260
} else {
234261
output[key] = sourceValue;
235262
}

0 commit comments

Comments
 (0)