diff --git a/src/gateway/embeddings-http.ts b/src/gateway/embeddings-http.ts index c42053db1ca..f08c8ecba7c 100644 --- a/src/gateway/embeddings-http.ts +++ b/src/gateway/embeddings-http.ts @@ -13,6 +13,7 @@ import type { MemoryEmbeddingProvider, MemoryEmbeddingProviderAdapter, } from "../plugins/memory-embedding-providers.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson } from "./http-common.js"; @@ -175,7 +176,7 @@ function resolveEmbeddingsTarget(params: { return { provider: params.configuredProvider, model: raw }; } - const provider = raw.slice(0, slash).trim().toLowerCase(); + const provider = normalizeLowercaseStringOrEmpty(raw.slice(0, slash)); const model = raw.slice(slash + 1).trim(); if (!model) { return { errorMessage: "Unsupported embedding model reference." }; diff --git a/src/gateway/origin-check.ts b/src/gateway/origin-check.ts index d6795a7b64e..0fed1c2dc1d 100644 --- a/src/gateway/origin-check.ts +++ b/src/gateway/origin-check.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isLoopbackHost, normalizeHostHeader } from "./net.js"; type OriginCheckResult = @@ -39,7 +40,9 @@ export function checkBrowserOrigin(params: { } const allowlist = new Set( - (params.allowedOrigins ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean), + (params.allowedOrigins ?? []) + .map((value) => normalizeOptionalLowercaseString(value)) + .filter(Boolean), ); if (allowlist.has("*") || allowlist.has(parsedOrigin.origin)) { return { ok: true, matchedBy: "allowlist" }; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 97854c6a50d..27adaeec0ae 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -16,6 +16,7 @@ import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveHookExternalContentSource as resolveHookExternalContentSourceFromSession } from "../security/external-content.js"; import { safeEqualSecret } from "../security/secret-equal.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, createAuthRateLimiter, @@ -97,8 +98,7 @@ function resolveMappedHookExternalContentSource(params: { payload: Record; sessionKey: string; }) { - const payloadSource = - typeof params.payload.source === "string" ? params.payload.source.trim().toLowerCase() : ""; + const payloadSource = normalizeLowercaseStringOrEmpty(params.payload.source); if (params.subPath === "gmail" || payloadSource === "gmail") { return "gmail" as const; } diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 67e2c1e038c..7d14aae3a28 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -3,6 +3,7 @@ import { deriveSessionTotalTokens, hasNonzeroUsage, normalizeUsage } from "../ag import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; import { extractAssistantVisibleText } from "../shared/chat-message-content.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { stripEnvelope } from "./chat-sanitize.js"; @@ -631,7 +632,7 @@ function truncatePreviewText(text: string, maxChars: number): string { } function extractPreviewText(message: TranscriptPreviewMessage): string | null { - const role = typeof message.role === "string" ? message.role.trim().toLowerCase() : ""; + const role = normalizeLowercaseStringOrEmpty(message.role); if (role === "assistant") { const assistantText = extractAssistantVisibleText(message); if (assistantText) { @@ -674,7 +675,7 @@ function extractMediaSummary(message: TranscriptPreviewMessage): string | null { return null; } for (const entry of message.content) { - const raw = typeof entry?.type === "string" ? entry.type.trim().toLowerCase() : ""; + const raw = normalizeLowercaseStringOrEmpty(entry?.type); if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") { continue; } diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 25731f1c94d..d6482c9bd19 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import { isDispatchWrapperExecutable } from "./dispatch-wrapper-resolution.js"; import { analyzeShellCommand, @@ -48,7 +51,7 @@ export function normalizeSafeBins(entries?: readonly string[]): Set { return new Set(); } const normalized = entries - .map((entry) => entry.trim().toLowerCase()) + .map((entry) => normalizeLowercaseStringOrEmpty(entry)) .filter((entry) => entry.length > 0); return new Set(normalized); } @@ -978,11 +981,11 @@ function collectAllowAlwaysPatterns(params: { const isPowerShellFileInvocation = POWERSHELL_WRAPPERS.has(normalizeExecutableToken(segment.argv[0] ?? "")) && segment.argv.some((t) => { - const lower = t.trim().toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(t); return lower === "-file" || lower === "-f"; }) && !segment.argv.some((t) => { - const lower = t.trim().toLowerCase(); + const lower = normalizeLowercaseStringOrEmpty(t); return lower === "-command" || lower === "-c" || lower === "--command"; }); const inlineCommand = isPowerShellFileInvocation diff --git a/src/infra/exec-safe-bin-semantics.ts b/src/infra/exec-safe-bin-semantics.ts index eda28d2380c..7e97a38fcba 100644 --- a/src/infra/exec-safe-bin-semantics.ts +++ b/src/infra/exec-safe-bin-semantics.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; + export type SafeBinSemanticValidationParams = { binName?: string; positional: readonly string[]; @@ -53,7 +55,7 @@ const SAFE_BIN_SEMANTIC_RULES: Readonly> = { }; export function normalizeSafeBinName(raw: string): string { - const trimmed = raw.trim().toLowerCase(); + const trimmed = normalizeLowercaseStringOrEmpty(raw); if (!trimmed) { return ""; } diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 6f483ea2704..6ec4ed74ed8 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -27,6 +27,7 @@ import type { AgentToolsConfig } from "../config/types.tools.js"; import { readInstalledPackageVersion } from "../infra/package-update-utils.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { formatPermissionDetail, formatPermissionRemediation, @@ -239,7 +240,11 @@ function resolveToolPolicies(params: { } function normalizePluginIdSet(entries: string[]): Set { - return new Set(entries.map((entry) => entry.trim().toLowerCase()).filter(Boolean)); + return new Set( + entries + .map((entry) => normalizeOptionalLowercaseString(entry)) + .filter((entry): entry is string => Boolean(entry)), + ); } function resolveEnabledExtensionPluginIds(params: { @@ -255,12 +260,16 @@ function resolveEnabledExtensionPluginIds(params: { const denySet = normalizePluginIdSet(normalized.deny); const entryById = new Map(); for (const [id, entry] of Object.entries(normalized.entries)) { - entryById.set(id.trim().toLowerCase(), entry); + const normalizedId = normalizeOptionalLowercaseString(id); + if (!normalizedId) { + continue; + } + entryById.set(normalizedId, entry); } const enabled: string[] = []; for (const id of params.pluginDirs) { - const normalizedId = id.trim().toLowerCase(); + const normalizedId = normalizeOptionalLowercaseString(id); if (!normalizedId) { continue; } @@ -286,7 +295,9 @@ function collectAllowEntries(config?: { allow?: string[]; alsoAllow?: string[] } if (Array.isArray(config?.alsoAllow)) { out.push(...config.alsoAllow); } - return out.map((entry) => entry.trim().toLowerCase()).filter(Boolean); + return out + .map((entry) => normalizeOptionalLowercaseString(entry)) + .filter((entry): entry is string => Boolean(entry)); } function hasExplicitPluginAllow(params: { @@ -496,7 +507,7 @@ function parsePublishedHostFromDockerPortLine(line: string): string | null { } function isLoopbackPublishHost(host: string): boolean { - const normalized = host.trim().toLowerCase(); + const normalized = normalizeOptionalLowercaseString(host); return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; } diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 62d171fe1cd..78a2333cfbe 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -27,6 +27,7 @@ import { DEFAULT_DANGEROUS_NODE_COMMANDS, resolveNodeCommandAllowlist, } from "../gateway/node-command-policy.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; export type SecurityAuditFinding = { @@ -815,7 +816,7 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu const seccompProfile = typeof docker.seccompProfile === "string" ? docker.seccompProfile : undefined; - if (seccompProfile && seccompProfile.trim().toLowerCase() === "unconfined") { + if (normalizeOptionalLowercaseString(seccompProfile) === "unconfined") { findings.push({ checkId: "sandbox.dangerous_seccomp_profile", severity: "critical", @@ -827,7 +828,7 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu const apparmorProfile = typeof docker.apparmorProfile === "string" ? docker.apparmorProfile : undefined; - if (apparmorProfile && apparmorProfile.trim().toLowerCase() === "unconfined") { + if (normalizeOptionalLowercaseString(apparmorProfile) === "unconfined") { findings.push({ checkId: "sandbox.dangerous_apparmor_profile", severity: "critical", @@ -842,7 +843,7 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu const defaultBrowser = resolveSandboxConfigForAgent(cfg).browser; if ( defaultBrowser.enabled && - defaultBrowser.network.trim().toLowerCase() === "bridge" && + normalizeOptionalLowercaseString(defaultBrowser.network) === "bridge" && !defaultBrowser.cdpSourceRange?.trim() ) { browserExposurePaths.push("agents.defaults.sandbox.browser"); @@ -855,7 +856,7 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu if (!browser.enabled) { continue; } - if (browser.network.trim().toLowerCase() !== "bridge") { + if (normalizeOptionalLowercaseString(browser.network) !== "bridge") { continue; } if (browser.cdpSourceRange?.trim()) { diff --git a/src/security/external-content.ts b/src/security/external-content.ts index 71019f4a815..034b7ffe405 100644 --- a/src/security/external-content.ts +++ b/src/security/external-content.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; /** * Security utilities for handling untrusted external content. @@ -109,7 +110,7 @@ const EXTERNAL_SOURCE_LABELS: Record = { export function resolveHookExternalContentSource( sessionKey: string, ): HookExternalContentSource | undefined { - const normalized = sessionKey.trim().toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(sessionKey); if (normalized.startsWith("hook:gmail:")) { return "gmail"; } diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts index c7580bbc42c..546b554330f 100644 --- a/src/security/windows-acl.ts +++ b/src/security/windows-acl.ts @@ -1,5 +1,6 @@ import os from "node:os"; import { runExec } from "../process/exec.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; export type ExecFn = typeof runExec; @@ -63,7 +64,7 @@ const STATUS_PREFIXES = [ "no mapping between account names", ]; -const normalize = (value: string) => value.trim().toLowerCase(); +const normalize = (value: string) => normalizeLowercaseStringOrEmpty(value); function normalizeSid(value: string): string { const normalized = normalize(value);