fix: make sensitive field whitelist case-insensitive (#16148)

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

Prepared head SHA: bb2d219e1f577c2fc8e4a11b2819251d14d5337e
Co-authored-by: akramcodez <179671552+akramcodez@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Sk Akram
2026-02-15 21:01:48 +05:30
committed by GitHub
parent 6565ec2e53
commit 1911942363
5 changed files with 74 additions and 8 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.

View File

@@ -179,6 +179,29 @@ describe("redactConfigSnapshot", () => {
expect(gw.auth.token).toBe(REDACTED_SENTINEL);
});
it("does not redact passwordFile path fields", () => {
const snapshot = makeSnapshot({
channels: {
irc: {
passwordFile: "/etc/openclaw/irc-password.txt",
nickserv: {
passwordFile: "/etc/openclaw/nickserv-password.txt",
password: "super-secret-nickserv-password",
},
},
},
});
const result = redactConfigSnapshot(snapshot);
const channels = result.config.channels as Record<string, Record<string, unknown>>;
const irc = channels.irc;
const nickserv = irc.nickserv as Record<string, unknown>;
expect(irc.passwordFile).toBe("/etc/openclaw/irc-password.txt");
expect(nickserv.passwordFile).toBe("/etc/openclaw/nickserv-password.txt");
expect(nickserv.password).toBe(REDACTED_SENTINEL);
});
it("preserves hash unchanged", () => {
const snapshot = makeSnapshot({ gateway: { auth: { token: "secret-token-value-here" } } });
const result = redactConfigSnapshot(snapshot);
@@ -343,7 +366,9 @@ describe("redactConfigSnapshot", () => {
});
const result = redactConfigSnapshot(snapshot, hints);
const custom = result.config.custom as Record<string, string>;
const resolved = result.resolved as Record<string, Record<string, string>>;
expect(custom.mySecret).toBe(REDACTED_SENTINEL);
expect(resolved.custom.mySecret).toBe(REDACTED_SENTINEL);
});
it("keeps regex fallback for extension keys not covered by uiHints", () => {
@@ -630,7 +655,9 @@ describe("redactConfigSnapshot", () => {
});
const result = redactConfigSnapshot(snapshot, hints);
const gw = result.config.gateway as Record<string, Record<string, string>>;
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
expect(gw.auth.token).toBe("not-actually-secret-value");
expect(resolved.gateway.auth.token).toBe("not-actually-secret-value");
});
it("does not redact paths absent from uiHints (schema is single source of truth)", () => {
@@ -642,7 +669,9 @@ describe("redactConfigSnapshot", () => {
});
const result = redactConfigSnapshot(snapshot, hints);
const gw = result.config.gateway as Record<string, Record<string, string>>;
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
expect(gw.auth.password).toBe("not-in-hints-value");
expect(resolved.gateway.auth.password).toBe("not-in-hints-value");
});
it("uses wildcard hints for array items", () => {

View File

@@ -301,7 +301,7 @@ export function redactConfigSnapshot(
const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config, uiHints) : null;
const redactedParsed = snapshot.parsed ? redactObject(snapshot.parsed, uiHints) : snapshot.parsed;
// Also redact the resolved config (contains values after ${ENV} substitution)
const redactedResolved = redactConfigObject(snapshot.resolved);
const redactedResolved = redactConfigObject(snapshot.resolved, uiHints);
return {
...snapshot,

View File

@@ -1,11 +1,38 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { __test__ } from "./schema.hints.js";
import { __test__, isSensitiveConfigPath } from "./schema.hints.js";
import { OpenClawSchema } from "./zod-schema.js";
import { sensitive } from "./zod-schema.sensitive.js";
const { mapSensitivePaths } = __test__;
describe("isSensitiveConfigPath", () => {
it("matches whitelist suffixes case-insensitively", () => {
const whitelistedPaths = [
"maxTokens",
"maxOutputTokens",
"maxInputTokens",
"maxCompletionTokens",
"contextTokens",
"totalTokens",
"tokenCount",
"tokenLimit",
"tokenBudget",
"channels.irc.nickserv.passwordFile",
];
for (const path of whitelistedPaths) {
expect(isSensitiveConfigPath(path)).toBe(false);
expect(isSensitiveConfigPath(path.toUpperCase())).toBe(false);
}
});
it("keeps true sensitive keys redacted", () => {
expect(isSensitiveConfigPath("channels.slack.token")).toBe(true);
expect(isSensitiveConfigPath("models.providers.openai.apiKey")).toBe(true);
expect(isSensitiveConfigPath("channels.irc.nickserv.password")).toBe(true);
});
});
describe("mapSensitivePaths", () => {
it("should detect sensitive fields nested inside all structural Zod types", () => {
const GrandSchema = z.object({

View File

@@ -91,7 +91,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
* These are explicitly excluded from redaction (plugin config) and
* warnings about not being marked sensitive (base config).
*/
const SENSITIVE_KEY_WHITELIST = new Set([
const SENSITIVE_KEY_WHITELIST_SUFFIXES = [
"maxtokens",
"maxoutputtokens",
"maxinputtokens",
@@ -102,15 +102,24 @@ const SENSITIVE_KEY_WHITELIST = new Set([
"tokenlimit",
"tokenbudget",
"passwordFile",
]);
] as const;
const NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES = SENSITIVE_KEY_WHITELIST_SUFFIXES.map((suffix) =>
suffix.toLowerCase(),
);
const SENSITIVE_PATTERNS = [/token$/i, /password/i, /secret/i, /api.?key/i];
function isWhitelistedSensitivePath(path: string): boolean {
const lowerPath = path.toLowerCase();
return NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix));
}
function matchesSensitivePattern(path: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
}
export function isSensitiveConfigPath(path: string): boolean {
return (
!Array.from(SENSITIVE_KEY_WHITELIST).some((suffix) => path.endsWith(suffix)) &&
SENSITIVE_PATTERNS.some((pattern) => pattern.test(path))
);
return !isWhitelistedSensitivePath(path) && matchesSensitivePattern(path);
}
export function buildBaseHints(): ConfigUiHints {