diff --git a/CHANGELOG.md b/CHANGELOG.md index 553e820a986..f81b88517f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. - Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. - Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. - Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 0973560c68b..c6079d7b0e9 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -656,7 +656,7 @@ describe("redactConfigSnapshot", () => { expectGatewayAuthFieldValue(result, "token", "not-actually-secret-value"); }); - it("does not redact paths absent from uiHints (schema is single source of truth)", () => { + it("redacts sensitive-looking paths even when absent from uiHints (defense in depth)", () => { const hints: ConfigUiHints = { "some.other.path": { sensitive: true }, }; @@ -664,7 +664,57 @@ describe("redactConfigSnapshot", () => { gateway: { auth: { password: "not-in-hints-value" } }, }); const result = redactConfigSnapshot(snapshot, hints); - expectGatewayAuthFieldValue(result, "password", "not-in-hints-value"); + expectGatewayAuthFieldValue(result, "password", REDACTED_SENTINEL); + }); + + it("redacts and restores dynamic env catchall secrets when uiHints miss the path", () => { + const hints: ConfigUiHints = { + "some.other.path": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + env: { + GROQ_API_KEY: "gsk-secret-123", + NODE_ENV: "production", + }, + }); + const redacted = redactConfigSnapshot(snapshot, hints); + const env = redacted.config.env as Record; + expect(env.GROQ_API_KEY).toBe(REDACTED_SENTINEL); + expect(env.NODE_ENV).toBe("production"); + + const restored = restoreRedactedValues(redacted.config, snapshot.config, hints); + expect(restored.env.GROQ_API_KEY).toBe("gsk-secret-123"); + expect(restored.env.NODE_ENV).toBe("production"); + }); + + it("redacts and restores skills entry env secrets in dynamic record paths", () => { + const hints: ConfigUiHints = { + "some.other.path": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + skills: { + entries: { + web_search: { + env: { + GEMINI_API_KEY: "gemini-secret-456", + BRAVE_REGION: "us", + }, + }, + }, + }, + }); + const redacted = redactConfigSnapshot(snapshot, hints); + const entry = ( + redacted.config.skills as { + entries: Record }>; + } + ).entries.web_search; + expect(entry.env.GEMINI_API_KEY).toBe(REDACTED_SENTINEL); + expect(entry.env.BRAVE_REGION).toBe("us"); + + const restored = restoreRedactedValues(redacted.config, snapshot.config, hints); + expect(restored.skills.entries.web_search.env.GEMINI_API_KEY).toBe("gemini-secret-456"); + expect(restored.skills.entries.web_search.env.BRAVE_REGION).toBe("us"); }); it("uses wildcard hints for array items", () => { diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index d377e961d53..f60470c9d4a 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -164,7 +164,10 @@ function redactObjectWithLookup( break; } } - if (!matched && isExtensionPath(path)) { + if (!matched) { + // Fall back to pattern-based guessing for paths not covered by schema + // hints. This catches dynamic keys inside catchall objects (for example + // env.GROQ_API_KEY) and extension/plugin config alike. const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]); if ( typeof value === "string" && @@ -542,7 +545,7 @@ function restoreRedactedValuesWithLookup( break; } } - if (!matched && isExtensionPath(path)) { + if (!matched) { const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]); if (!markedNonSensitive && isSensitivePath(path) && value === REDACTED_SENTINEL) { result[key] = restoreOriginalValueOrThrow({ key, path, original: orig });