Files
Moltbot/src/config/schema.hints.ts
Sk Akram 1911942363 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
2026-02-15 10:31:48 -05:00

235 lines
6.5 KiB
TypeScript

import { z } from "zod";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { FIELD_HELP } from "./schema.help.js";
import { FIELD_LABELS } from "./schema.labels.js";
import { sensitive } from "./zod-schema.sensitive.js";
const log = createSubsystemLogger("config/schema");
export type ConfigUiHint = {
label?: string;
help?: string;
group?: string;
order?: number;
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
itemTemplate?: unknown;
};
export type ConfigUiHints = Record<string, ConfigUiHint>;
const GROUP_LABELS: Record<string, string> = {
wizard: "Wizard",
update: "Update",
diagnostics: "Diagnostics",
logging: "Logging",
gateway: "Gateway",
nodeHost: "Node Host",
agents: "Agents",
tools: "Tools",
bindings: "Bindings",
audio: "Audio",
models: "Models",
messages: "Messages",
commands: "Commands",
session: "Session",
cron: "Cron",
hooks: "Hooks",
ui: "UI",
browser: "Browser",
talk: "Talk",
channels: "Messaging Channels",
skills: "Skills",
plugins: "Plugins",
discovery: "Discovery",
presence: "Presence",
voicewake: "Voice Wake",
};
const GROUP_ORDER: Record<string, number> = {
wizard: 20,
update: 25,
diagnostics: 27,
gateway: 30,
nodeHost: 35,
agents: 40,
tools: 50,
bindings: 55,
audio: 60,
models: 70,
messages: 80,
commands: 85,
session: 90,
cron: 100,
hooks: 110,
ui: 120,
browser: 130,
talk: 140,
channels: 150,
skills: 200,
plugins: 205,
discovery: 210,
presence: 220,
voicewake: 230,
logging: 900,
};
const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.remote.url": "ws://host:18789",
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
"gateway.remote.sshTarget": "user@host",
"gateway.controlUi.basePath": "/openclaw",
"gateway.controlUi.root": "dist/control-ui",
"gateway.controlUi.allowedOrigins": "https://control.example.com",
"channels.mattermost.baseUrl": "https://chat.example.com",
"agents.list[].identity.avatar": "avatars/openclaw.png",
};
/**
* Non-sensitive field names that happen to match sensitive patterns.
* These are explicitly excluded from redaction (plugin config) and
* warnings about not being marked sensitive (base config).
*/
const SENSITIVE_KEY_WHITELIST_SUFFIXES = [
"maxtokens",
"maxoutputtokens",
"maxinputtokens",
"maxcompletiontokens",
"contexttokens",
"totaltokens",
"tokencount",
"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 !isWhitelistedSensitivePath(path) && matchesSensitivePattern(path);
}
export function buildBaseHints(): ConfigUiHints {
const hints: ConfigUiHints = {};
for (const [group, label] of Object.entries(GROUP_LABELS)) {
hints[group] = {
label,
group: label,
order: GROUP_ORDER[group],
};
}
for (const [path, label] of Object.entries(FIELD_LABELS)) {
const current = hints[path];
hints[path] = current ? { ...current, label } : { label };
}
for (const [path, help] of Object.entries(FIELD_HELP)) {
const current = hints[path];
hints[path] = current ? { ...current, help } : { help };
}
for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) {
const current = hints[path];
hints[path] = current ? { ...current, placeholder } : { placeholder };
}
return hints;
}
export function applySensitiveHints(
hints: ConfigUiHints,
allowedKeys?: ReadonlySet<string>,
): ConfigUiHints {
const next = { ...hints };
for (const key of Object.keys(next)) {
if (allowedKeys && !allowedKeys.has(key)) {
continue;
}
if (next[key]?.sensitive !== undefined) {
continue;
}
if (isSensitiveConfigPath(key)) {
next[key] = { ...next[key], sensitive: true };
}
}
return next;
}
// Seems to be the only way tsgo accepts us to check if we have a ZodClass
// with an unwrap() method. And it's overly complex because oxlint and
// tsgo are each forbidding what the other allows.
interface ZodDummy {
unwrap: () => z.ZodType;
}
function isUnwrappable(object: unknown): object is ZodDummy {
return (
!!object &&
typeof object === "object" &&
"unwrap" in object &&
typeof (object as Record<string, unknown>).unwrap === "function" &&
!(object instanceof z.ZodArray)
);
}
export function mapSensitivePaths(
schema: z.ZodType,
path: string,
hints: ConfigUiHints,
): ConfigUiHints {
let next = { ...hints };
let currentSchema = schema;
let isSensitive = sensitive.has(currentSchema);
while (isUnwrappable(currentSchema)) {
currentSchema = currentSchema.unwrap();
isSensitive ||= sensitive.has(currentSchema);
}
if (isSensitive) {
next[path] = { ...next[path], sensitive: true };
} else if (isSensitiveConfigPath(path) && !next[path]?.sensitive) {
log.warn(`possibly sensitive key found: (${path})`);
}
if (currentSchema instanceof z.ZodObject) {
const shape = currentSchema.shape;
for (const key in shape) {
const nextPath = path ? `${path}.${key}` : key;
next = mapSensitivePaths(shape[key], nextPath, next);
}
} else if (currentSchema instanceof z.ZodArray) {
const nextPath = path ? `${path}[]` : "[]";
next = mapSensitivePaths(currentSchema.element as z.ZodType, nextPath, next);
} else if (currentSchema instanceof z.ZodRecord) {
const nextPath = path ? `${path}.*` : "*";
next = mapSensitivePaths(currentSchema._def.valueType as z.ZodType, nextPath, next);
} else if (
currentSchema instanceof z.ZodUnion ||
currentSchema instanceof z.ZodDiscriminatedUnion
) {
for (const option of currentSchema.options) {
next = mapSensitivePaths(option as z.ZodType, path, next);
}
} else if (currentSchema instanceof z.ZodIntersection) {
next = mapSensitivePaths(currentSchema._def.left as z.ZodType, path, next);
next = mapSensitivePaths(currentSchema._def.right as z.ZodType, path, next);
}
return next;
}
/** @internal */
export const __test__ = {
mapSensitivePaths,
};