fix(config): keep write inputs immutable when using unsetPaths (#24134)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 951f8480c3
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Frank Yang
2026-02-22 23:51:13 -08:00
committed by GitHub
parent 0e28e50b45
commit f208518cb9
5 changed files with 268 additions and 70 deletions

View File

@@ -204,6 +204,35 @@ describe("config cli", () => {
});
});
describe("path hardening", () => {
it("rejects blocked prototype-key segments for config get", async () => {
await expect(runConfigCommand(["config", "get", "gateway.__proto__.token"])).rejects.toThrow(
"Invalid path segment: __proto__",
);
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
it("rejects blocked prototype-key segments for config set", async () => {
await expect(
runConfigCommand(["config", "set", "tools.constructor.profile", '"sandbox"']),
).rejects.toThrow("Invalid path segment: constructor");
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
it("rejects blocked prototype-key segments for config unset", async () => {
await expect(
runConfigCommand(["config", "unset", "channels.prototype.enabled"]),
).rejects.toThrow("Invalid path segment: prototype");
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
});
describe("config unset - issue #6070", () => {
it("preserves existing config keys when unsetting a value", async () => {
const resolved: OpenClawConfig = {

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import JSON5 from "json5";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { isBlockedObjectKey } from "../config/prototype-keys.js";
import { redactConfigObject } from "../config/redact-snapshot.js";
import { danger, info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -88,6 +89,18 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown {
}
}
function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function validatePathSegments(path: PathSegment[]): void {
for (const segment of path) {
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
throw new Error(`Invalid path segment: ${segment}`);
}
}
}
function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } {
let current: unknown = root;
for (const segment of path) {
@@ -106,7 +119,7 @@ function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?
continue;
}
const record = current as Record<string, unknown>;
if (!(segment in record)) {
if (!hasOwnPathKey(record, segment)) {
return { found: false };
}
current = record[segment];
@@ -136,7 +149,7 @@ function setAtPath(root: Record<string, unknown>, path: PathSegment[], value: un
throw new Error(`Cannot traverse into "${segment}" (not an object)`);
}
const record = current as Record<string, unknown>;
const existing = record[segment];
const existing = hasOwnPathKey(record, segment) ? record[segment] : undefined;
if (!existing || typeof existing !== "object") {
record[segment] = nextIsIndex ? [] : {};
}
@@ -177,7 +190,7 @@ function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolea
continue;
}
const record = current as Record<string, unknown>;
if (!(segment in record)) {
if (!hasOwnPathKey(record, segment)) {
return false;
}
current = record[segment];
@@ -199,7 +212,7 @@ function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolea
return false;
}
const record = current as Record<string, unknown>;
if (!(last in record)) {
if (!hasOwnPathKey(record, last)) {
return false;
}
delete record[last];
@@ -225,6 +238,7 @@ function parseRequiredPath(path: string): PathSegment[] {
if (parsedPath.length === 0) {
throw new Error("Path is empty.");
}
validatePathSegments(parsedPath);
return parsedPath;
}
@@ -322,10 +336,7 @@ export function registerConfigCli(program: Command) {
.option("--json", "Legacy alias for --strict-json", false)
.action(async (path: string, value: string, opts) => {
try {
const parsedPath = parsePath(path);
if (parsedPath.length === 0) {
throw new Error("Path is empty.");
}
const parsedPath = parseRequiredPath(path);
const parsedValue = parseValue(value, {
strictJson: Boolean(opts.strictJson || opts.json),
});