diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index c6079d7b0e9..ee3dc62b421 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -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; + skills: { entries: Record }> }; + broadcast: Record; + }; + + 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 }, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index f60470c9d4a..91b2e76f990 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -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({ diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 42476a566e6..dec154d0485 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -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", diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index d788a87d701..06fa93efea5 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -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);