fix(a2a-server): deep-merge user and workspace settings#28094
fix(a2a-server): deep-merge user and workspace settings#28094AriaZhao-coder wants to merge 3 commits into
Conversation
`loadSettings()` combined user and workspace settings with a shallow object spread, so any nested section (`tools`, `telemetry`, `fileFiltering`, `experimental`, ...) defined in workspace settings replaced the entire corresponding section from user settings, silently dropping unrelated user-level configuration. Replace the shallow spread with a self-contained recursive deep merge: nested plain objects are merged key-by-key, while arrays and primitive values from workspace settings still override the user values. The merge skips `__proto__`/`constructor`/`prototype` keys to prevent prototype pollution and ignores `undefined` source values. The a2a-server only depends on core, so this avoids reaching for the cli-only `customDeepMerge`. Update the existing test that encoded the shallow-merge bug, and add coverage for multi-section deep merge, array replacement, and prototype pollution safety. Fixes google-gemini#25747
|
📊 PR Size: size/M
|
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses a configuration merging issue in Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a custom recursive deep merge function (deepMergeSettings) to merge user and workspace settings, along with corresponding unit tests. The review feedback identifies two critical issues in the implementation: a potential server crash if settings are parsed as null, and a reference-sharing issue where nested source objects are assigned directly instead of being deeply cloned.
| function deepMergeSettings<T extends object>(target: T, source: T): T { | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | ||
| const output = { ...target } as Record<string, unknown>; | ||
|
|
||
| for (const [key, sourceValue] of Object.entries(source)) { |
There was a problem hiding this comment.
If either target or source is null (which can happen if a settings file is empty or contains null, as JSON.parse("null") returns null), calling Object.entries(source) will throw a TypeError: Cannot convert undefined or null to object and crash the server.
To prevent this, we should safely default target and source to empty objects if they are not plain objects.
| function deepMergeSettings<T extends object>(target: T, source: T): T { | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
| const output = { ...target } as Record<string, unknown>; | |
| for (const [key, sourceValue] of Object.entries(source)) { | |
| function deepMergeSettings<T extends object>(target: T, source: T): T { | |
| const t = isPlainObject(target) ? target : {}; | |
| const s = isPlainObject(source) ? source : {}; | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
| const output = { ...t } as Record<string, unknown>; | |
| for (const [key, sourceValue] of Object.entries(s)) { |
| const targetValue = output[key]; | ||
| if (isPlainObject(targetValue) && isPlainObject(sourceValue)) { | ||
| output[key] = deepMergeSettings(targetValue, sourceValue); | ||
| } else { | ||
| output[key] = sourceValue; | ||
| } |
There was a problem hiding this comment.
When sourceValue is a plain object but targetValue is not (e.g., it is undefined), the code currently assigns sourceValue directly by reference: output[key] = sourceValue;.
This means the merged settings object will share references with the original source (workspace settings) object. Any subsequent mutations to the merged settings could unintentionally mutate the workspace settings object.
To align with the repository's established deep-merge behavior (as seen in packages/cli/src/utils/deepMerge.ts), we should recursively clone sourceValue when targetValue is not a plain object.
const targetValue = output[key];
if (isPlainObject(targetValue) && isPlainObject(sourceValue)) {
output[key] = deepMergeSettings(targetValue, sourceValue);
} else if (isPlainObject(sourceValue)) {
output[key] = deepMergeSettings({}, sourceValue);
} else {
output[key] = sourceValue;
}…ted values Address review feedback on google-gemini#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>
…ted values Address review feedback on google-gemini#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).
4d99c73 to
7b08194
Compare
|
Thanks for the review! I've addressed both high-priority points in the latest commit (
Added unit tests covering both (null-safety + reference cloning) plus /gemini review |
There was a problem hiding this comment.
Code Review
This pull request replaces the shallow merge of user and workspace settings with a recursive deep merge implementation (deepMergeSettings) to preserve unrelated nested user configurations. It also introduces normalization to safely handle null or empty settings files and adds protection against prototype pollution. Feedback is provided regarding the deep merge implementation, which currently copies array references directly; cloning arrays using structuredClone is recommended to prevent reference sharing and unintended mutations.
| } else if (isPlainObject(sourceValue)) { | ||
| // The source value is a nested object but the target value is not, so | ||
| // recursively clone it. Assigning `sourceValue` directly would make the | ||
| // merged result share references with the original `source` object, | ||
| // allowing later mutations to leak back into it. | ||
| output[key] = deepMergeSettings({}, sourceValue); | ||
| } else { | ||
| output[key] = sourceValue; | ||
| } |
There was a problem hiding this comment.
While the deep merge implementation correctly clones nested plain objects to prevent reference sharing, it currently misses cloning arrays. If a workspace setting contains an array (such as fileFiltering.customIgnoreFilePaths), the array reference is copied directly from the source object to the merged target. Any subsequent mutations to the merged settings will leak back and mutate the original source object, which can cause unexpected side effects or test leakage.
Additionally, security-sensitive settings (such as tools.allowed) must not use a merge strategy that allows less-trusted configuration scopes (like a workspace) to override more-trusted scopes (like global user settings). Ensure that these sensitive settings are protected from being overridden by workspace configurations.
For cloning arrays safely, we can use structuredClone to avoid reference sharing.
} else if (isPlainObject(sourceValue)) {
// The source value is a nested object but the target value is not, so
// recursively clone it. Assigning sourceValue directly would make the
// merged result share references with the original source object,
// allowing later mutations to leak back into it.
output[key] = deepMergeSettings({}, sourceValue);
} else if (Array.isArray(sourceValue)) {
// Clone arrays to prevent reference sharing.
output[key] = structuredClone(sourceValue);
} else {
output[key] = sourceValue;
}References
- Security-sensitive settings should not use a merge strategy (e.g.,
MergeStrategy.REPLACE) that allows less-trusted configuration scopes (like a workspace) to completely override more-trusted scopes (like global user settings).
Follow-up to the deep-merge review on google-gemini#28094: arrays were still copied by reference from the source (workspace) settings into the merged result, so mutating a merged array (e.g. fileFiltering.customIgnoreFilePaths) would leak back and mutate the original source object. Clone arrays with structuredClone when they replace the target value, mirroring the nested-object cloning already in place. Adds a unit test asserting the merged array is a distinct reference and mutation-isolated.
|
Thanks for the follow-up review! Addressed in
Regarding the note on security-sensitive settings (e.g.
Happy to revisit the trust model for tool settings in a dedicated PR if maintainers feel it's warranted. PTAL 🙏 |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a deep-merge capability for settings in the a2a-server package, replacing the previous shallow merge behavior. It adds a robust deepMergeSettings utility that recursively merges nested configuration objects, prevents prototype pollution by filtering out dangerous keys like __proto__, and avoids shared references by cloning nested objects and arrays. Additionally, it introduces normalizeSettings to safely handle empty or null settings files, preventing server crashes. Comprehensive unit tests have been added to verify these merging behaviors, cloning safety, and security protections. There are no review comments to address, and I have no further feedback to provide as the implementation is solid and well-tested.
|
Hi there! Thank you for your interest in contributing to Gemini CLI. To ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'. This PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding. |
Summary
loadSettings()inpackages/a2a-server/src/config/settings.tscombined user and workspace settings with a shallow object spread ({ ...userSettings, ...workspaceSettings }).Any nested section (
tools,telemetry,fileFiltering,experimental, ...) defined in workspace settings replaced the entire corresponding section from user settings, silently dropping unrelated user-level configuration.Example
User settings:
{ "fileFiltering": { "respectGitIgnore": true, "enableRecursiveFileSearch": true } }Workspace settings:
{ "fileFiltering": { "respectGitIgnore": false } }enableRecursiveFileSearchis silently lost.respectGitIgnoreis overridden.Fix
Replace the shallow spread with a self-contained recursive deep merge:
__proto__/constructor/prototypekeys are skipped to prevent prototype pollution;undefinedsource values are ignored.The a2a-server only depends on
core, so this intentionally avoids the cli-onlycustomDeepMerge(which is coupled to cli'sMergeStrategy/settingsSchema). ThepolicyPaths/adminPolicyPathssecurity override is unchanged.Tests
undefined).Verified locally (Node 20):
a2a-serversuite — all 140 tests pass.prettier --check,eslint --max-warnings 0,tsc --noEmit— all clean.Fixes #25747