refactor(config): harden catchall hint mapping and array fallback

This commit is contained in:
Peter Steinberger
2026-02-24 00:59:44 +00:00
parent 30c622554f
commit 13478cc79a
4 changed files with 74 additions and 15 deletions

View File

@@ -717,6 +717,47 @@ describe("redactConfigSnapshot", () => {
expect(restored.skills.entries.web_search.env.BRAVE_REGION).toBe("us");
});
it("contract-covers dynamic catchall/record paths for redact+restore", () => {
const hints = mapSensitivePaths(OpenClawSchema, "", {});
const snapshot = makeSnapshot({
env: {
GROQ_API_KEY: "gsk-contract-123",
NODE_ENV: "production",
},
skills: {
entries: {
web_search: {
env: {
GEMINI_API_KEY: "gemini-contract-456",
BRAVE_REGION: "us",
},
},
},
},
broadcast: {
apiToken: ["broadcast-secret-1", "broadcast-secret-2"],
channels: ["ops", "eng"],
},
});
const redacted = redactConfigSnapshot(snapshot, hints);
const config = redacted.config as {
env: Record<string, string>;
skills: { entries: Record<string, { env: Record<string, string> }> };
broadcast: Record<string, string[]>;
};
expect(config.env.GROQ_API_KEY).toBe(REDACTED_SENTINEL);
expect(config.env.NODE_ENV).toBe("production");
expect(config.skills.entries.web_search.env.GEMINI_API_KEY).toBe(REDACTED_SENTINEL);
expect(config.skills.entries.web_search.env.BRAVE_REGION).toBe("us");
expect(config.broadcast.apiToken).toEqual([REDACTED_SENTINEL, REDACTED_SENTINEL]);
expect(config.broadcast.channels).toEqual(["ops", "eng"]);
const restored = restoreRedactedValues(redacted.config, snapshot.config, hints);
expect(restored).toEqual(snapshot.config);
});
it("uses wildcard hints for array items", () => {
const hints: ConfigUiHints = {
"channels.slack.accounts[].botToken": { sensitive: true },

View File

@@ -17,15 +17,6 @@ function isEnvVarPlaceholder(value: string): boolean {
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
}
function isExtensionPath(path: string): boolean {
return (
path === "plugins" ||
path.startsWith("plugins.") ||
path === "channels" ||
path.startsWith("channels.")
);
}
function isExplicitlyNonSensitivePath(hints: ConfigUiHints | undefined, paths: string[]): boolean {
if (!hints) {
return false;
@@ -130,9 +121,8 @@ function redactObjectWithLookup(
if (Array.isArray(obj)) {
const path = `${prefix}[]`;
if (!lookup.has(path)) {
if (!isExtensionPath(prefix)) {
return obj;
}
// Keep behavior symmetric with object fallback: if hints miss the path,
// still run pattern-based guessing for non-extension arrays.
return redactObjectGuessing(obj, prefix, values, hints);
}
return obj.map((item) => {
@@ -507,9 +497,8 @@ function restoreRedactedValuesWithLookup(
// sensitive string array in the config...
const { incoming: incomingArray, path } = arrayContext;
if (!lookup.has(path)) {
if (!isExtensionPath(prefix)) {
return incomingArray;
}
// Keep behavior symmetric with object fallback: if hints miss the path,
// still run pattern-based guessing for non-extension arrays.
return restoreRedactedValuesGuessing(incomingArray, original, prefix, hints);
}
return mapRedactedArray({

View File

@@ -98,6 +98,30 @@ describe("mapSensitivePaths", () => {
expect(result["merged.nested"]?.sensitive).toBe(undefined);
});
it("maps sensitive fields nested under object catchall schemas", () => {
const schema = z.object({
custom: z.object({}).catchall(
z.object({
apiKey: z.string().register(sensitive),
label: z.string(),
}),
),
});
const result = mapSensitivePaths(schema, "", {});
expect(result["custom.*.apiKey"]?.sensitive).toBe(true);
expect(result["custom.*.label"]?.sensitive).toBe(undefined);
});
it("does not mark plain catchall values sensitive by default", () => {
const schema = z.object({
env: z.object({}).catchall(z.string()),
});
const result = mapSensitivePaths(schema, "", {});
expect(result["env.*"]?.sensitive).toBe(undefined);
});
it("main schema yields correct hints (samples)", () => {
const schema = OpenClawSchema.toJSONSchema({
target: "draft-07",

View File

@@ -209,6 +209,11 @@ export function mapSensitivePaths(
const nextPath = path ? `${path}.${key}` : key;
next = mapSensitivePaths(shape[key], nextPath, next);
}
const catchallSchema = currentSchema._def.catchall as z.ZodType | undefined;
if (catchallSchema && !(catchallSchema instanceof z.ZodNever)) {
const nextPath = path ? `${path}.*` : "*";
next = mapSensitivePaths(catchallSchema, nextPath, next);
}
} else if (currentSchema instanceof z.ZodArray) {
const nextPath = path ? `${path}[]` : "[]";
next = mapSensitivePaths(currentSchema.element as z.ZodType, nextPath, next);