openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
575 lines (574 loc) • 22.2 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js";
import { o as isRecord } from "./record-coerce-DHZ4bFlT.js";
import { _ as uniqueStrings } from "./string-normalization-WNUDCpXX.js";
import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js";
import { n as hasSensitiveUrlHintTag, o as redactSensitiveUrlLikeString, r as isSensitiveUrlConfigPath } from "./redact-sensitive-url-Cf2Fdzd5.js";
import { n as redactSecretRefId, t as isSecretRefShape } from "./redact-snapshot.secret-ref-C5jAxjdG.js";
import JSON5 from "json5";
import { isDeepStrictEqual } from "node:util";
//#region src/config/redact-snapshot.raw.ts
/** Replaces known sensitive values in raw config text while preserving parseable structure. */
function replaceSensitiveValuesInRaw(params) {
const values = uniqueStrings(params.sensitiveValues).filter((value) => value !== "").toSorted((a, b) => b.length - a.length);
let result = params.raw;
for (const value of values) result = result.replaceAll(value, params.redactedSentinel);
return result;
}
/** Returns whether raw string redaction changed semantics and structured redaction is needed. */
function shouldFallbackToStructuredRawRedaction(params) {
try {
const parsed = JSON5.parse(params.redactedRaw);
const restored = params.restoreParsed(parsed);
if (!restored.ok) return true;
return !isDeepStrictEqual(restored.result, params.originalConfig);
} catch {
return true;
}
}
//#endregion
//#region src/config/sensitive-paths.ts
const NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES = [
"maxtokens",
"maxoutputtokens",
"maxinputtokens",
"maxcompletiontokens",
"contexttokens",
"totaltokens",
"tokencount",
"tokenlimit",
"tokenbudget",
"passwordFile"
].map((suffix) => normalizeLowercaseStringOrEmpty(suffix));
const SENSITIVE_PATTERNS = [
/token$/i,
/password/i,
/secret/i,
/api.?key/i,
/encrypt.?key/i,
/private.?key/i,
/serviceaccount(?:ref)?$/i
];
function isWhitelistedSensitivePath(path) {
const lowerPath = normalizeLowercaseStringOrEmpty(path);
return NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix));
}
function matchesSensitivePattern(path) {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
}
function isLocalServiceEnvValuePath(path) {
return normalizeLowercaseStringOrEmpty(path).includes("localservice.env.");
}
/**
* Classifies config paths whose values should be redacted from UI/API output.
*
* This intentionally works from path labels, not schema nodes, so plugin-owned
* fields and raw local-service env vars get the same conservative treatment.
*/
function isSensitiveConfigPath(path) {
return isLocalServiceEnvValuePath(path) || !isWhitelistedSensitivePath(path) && matchesSensitivePattern(path);
}
//#endregion
//#region src/config/redact-snapshot.ts
const log = createSubsystemLogger("config/redaction");
const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/;
function isSensitivePath(path) {
if (path.endsWith("[]")) return isSensitiveConfigPath(path.slice(0, -2));
return isSensitiveConfigPath(path);
}
function isEnvVarPlaceholder(value) {
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
}
function isWholeObjectSensitivePath(path) {
const lowered = normalizeLowercaseStringOrEmpty(path);
return lowered.endsWith("serviceaccount") || lowered.endsWith("serviceaccountref");
}
function isSensitiveUrlPath(path) {
return isSensitiveUrlConfigPath(path);
}
function hasSensitiveUrlHintPath(hints, paths) {
if (!hints) return false;
return paths.some((path) => hasSensitiveUrlHintTag(hints[path]));
}
function collectSensitiveStrings(value, values) {
if (typeof value === "string") {
if (!isEnvVarPlaceholder(value)) values.push(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) collectSensitiveStrings(item, values);
return;
}
if (isRecord(value)) {
const obj = value;
if (isSecretRefShape(obj)) {
if (!isEnvVarPlaceholder(obj.id)) values.push(obj.id);
return;
}
for (const item of Object.values(obj)) collectSensitiveStrings(item, values);
}
}
function isExplicitlyNonSensitivePath(hints, paths) {
if (!hints) return false;
return paths.some((path) => hints[path]?.sensitive === false);
}
/**
* Sentinel value used to replace sensitive config fields in gateway responses.
* Write-side handlers (config.set, config.apply, config.patch) detect this
* sentinel and restore the original value from the on-disk config, so a
* round-trip through the Web UI does not corrupt credentials.
*/
const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
function isSecretRefWithProvider(value) {
return isSecretRefShape(value) && typeof value.provider === "string";
}
function buildRedactionLookup(hints) {
const result = /* @__PURE__ */ new Set();
for (const [path, hint] of Object.entries(hints)) {
if (!hint.sensitive) continue;
const parts = path.split(".");
let joinedPath = parts.shift() ?? "";
result.add(joinedPath);
if (joinedPath.endsWith("[]")) result.add(joinedPath.slice(0, -2));
for (const part of parts) {
if (part.endsWith("[]")) result.add(`${joinedPath}.${part.slice(0, -2)}`);
joinedPath = `${joinedPath}.${part}`;
result.add(joinedPath);
}
}
if (result.size !== 0) result.add("");
return result;
}
function redactObject(obj, hints) {
if (hints) {
const lookup = buildRedactionLookup(hints);
return lookup.has("") ? redactObjectWithLookup(obj, lookup, "", [], hints) : redactObjectGuessing(obj, "", [], hints);
}
return redactObjectGuessing(obj, "", []);
}
/**
* Collect all sensitive string values from a config object.
* Used for text-based redaction of the raw JSON5 source.
*/
function collectSensitiveValues(obj, hints) {
const result = [];
if (hints) {
const lookup = buildRedactionLookup(hints);
if (lookup.has("")) redactObjectWithLookup(obj, lookup, "", result, hints);
else redactObjectGuessing(obj, "", result, hints);
} else redactObjectGuessing(obj, "", result);
return result;
}
/**
* Worker for redactObject() and collectSensitiveValues().
* Used when there are ConfigUiHints available.
*/
function redactObjectWithLookup(obj, lookup, prefix, values, hints) {
if (obj === null || obj === void 0) return obj;
if (Array.isArray(obj)) {
const path = `${prefix}[]`;
if (!lookup.has(path)) return redactObjectGuessing(obj, prefix, values, hints);
return obj.map((item) => {
if (typeof item === "string" && !isEnvVarPlaceholder(item)) {
values.push(item);
return REDACTED_SENTINEL;
}
return redactObjectWithLookup(item, lookup, path, values, hints);
});
}
if (isRecord(obj)) {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
const wildcardPath = prefix ? `${prefix}.*` : "*";
let matched = false;
for (const candidate of [path, wildcardPath]) {
result[key] = value;
if (lookup.has(candidate)) {
matched = true;
if (typeof value === "string" && !isEnvVarPlaceholder(value)) {
result[key] = REDACTED_SENTINEL;
values.push(value);
} else if (typeof value === "object" && value !== null) if (hints[candidate]?.sensitive === true && !Array.isArray(value)) {
const objectValue = toObjectRecord(value);
if (isSecretRefShape(objectValue)) result[key] = redactSecretRefId({
value: objectValue,
values,
redactedSentinel: REDACTED_SENTINEL,
isEnvVarPlaceholder
});
else {
collectSensitiveStrings(objectValue, values);
result[key] = REDACTED_SENTINEL;
}
} else result[key] = redactObjectWithLookup(value, lookup, candidate, values, hints);
else if (hints[candidate]?.sensitive === true && value !== void 0 && value !== null) result[key] = REDACTED_SENTINEL;
else if (typeof value === "string" && (hasSensitiveUrlHintPath(hints, [
candidate,
path,
wildcardPath
]) || isSensitiveUrlPath(path))) if (redactSensitiveUrlLikeString(value) !== value) {
values.push(value);
result[key] = REDACTED_SENTINEL;
} else result[key] = value;
break;
}
}
if (!matched) {
const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]);
if (typeof value === "string" && !markedNonSensitive && isSensitivePath(path) && !isEnvVarPlaceholder(value)) {
result[key] = REDACTED_SENTINEL;
values.push(value);
} else if (typeof value === "string" && (hasSensitiveUrlHintPath(hints, [path, wildcardPath]) || isSensitiveUrlPath(path))) if (redactSensitiveUrlLikeString(value) !== value) {
values.push(value);
result[key] = REDACTED_SENTINEL;
} else result[key] = value;
else if (typeof value === "object" && value !== null) result[key] = redactObjectGuessing(value, path, values, hints);
}
}
return result;
}
return obj;
}
/**
* Worker for redactObject() and collectSensitiveValues().
* Used when ConfigUiHints are NOT available.
*/
function redactObjectGuessing(obj, prefix, values, hints) {
if (obj === null || obj === void 0) return obj;
if (Array.isArray(obj)) return obj.map((item) => {
const path = `${prefix}[]`;
if (!isExplicitlyNonSensitivePath(hints, [path]) && isSensitivePath(path) && typeof item === "string" && !isEnvVarPlaceholder(item)) {
values.push(item);
return REDACTED_SENTINEL;
}
return redactObjectGuessing(item, path, values, hints);
});
if (isRecord(obj)) {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const dotPath = prefix ? `${prefix}.${key}` : key;
const wildcardPath = prefix ? `${prefix}.*` : "*";
if (!isExplicitlyNonSensitivePath(hints, [dotPath, wildcardPath]) && isSensitivePath(dotPath) && typeof value === "string" && !isEnvVarPlaceholder(value)) {
result[key] = REDACTED_SENTINEL;
values.push(value);
} else if (!isExplicitlyNonSensitivePath(hints, [dotPath, wildcardPath]) && isSensitivePath(dotPath) && isWholeObjectSensitivePath(dotPath) && value && typeof value === "object" && !Array.isArray(value)) {
collectSensitiveStrings(value, values);
result[key] = REDACTED_SENTINEL;
} else if (typeof value === "string" && (hasSensitiveUrlHintPath(hints, [dotPath, wildcardPath]) || isSensitiveUrlPath(dotPath))) if (redactSensitiveUrlLikeString(value) !== value) {
values.push(value);
result[key] = REDACTED_SENTINEL;
} else result[key] = value;
else if (typeof value === "object" && value !== null) result[key] = redactObjectGuessing(value, dotPath, values, hints);
else result[key] = value;
}
return result;
}
return obj;
}
/**
* Replace known sensitive values in a raw JSON5 string with the sentinel.
* Values are replaced longest-first to avoid partial matches.
*/
function redactRawText(raw, config, hints) {
return replaceSensitiveValuesInRaw({
raw,
sensitiveValues: collectSensitiveValues(config, hints),
redactedSentinel: REDACTED_SENTINEL
});
}
let suppressRestoreWarnings = false;
function withRestoreWarningsSuppressed(fn) {
const prev = suppressRestoreWarnings;
suppressRestoreWarnings = true;
try {
return fn();
} finally {
suppressRestoreWarnings = prev;
}
}
/**
* Returns a copy of the config snapshot with all sensitive fields
* replaced by {@link REDACTED_SENTINEL}. The `hash` is preserved
* (it tracks config identity, not content).
*
* Both `config` (the parsed object) and `raw` (the JSON5 source) are scrubbed
* so no credential can leak through either path.
*
* When `uiHints` are provided, sensitivity is determined from the schema hints.
* Without hints, falls back to regex-based detection via `isSensitivePath()`.
*/
/**
* Redact sensitive fields from a plain config object (not a full snapshot).
* Used by write endpoints (config.set, config.patch, config.apply) to avoid
* leaking credentials in their responses.
*/
function redactConfigObject(value, uiHints) {
return redactObject(value, uiHints);
}
function redactConfigSnapshot(snapshot, uiHints) {
if (!snapshot.valid) {
const redactedConfig = {};
const redactedResolved = {};
return {
...snapshot,
sourceConfig: redactedResolved,
runtimeConfig: redactedConfig,
config: redactedConfig,
raw: null,
parsed: null,
resolved: redactedResolved
};
}
const redactedConfig = redactObject(snapshot.config, uiHints);
const redactedParsed = snapshot.parsed ? redactObject(snapshot.parsed, uiHints) : snapshot.parsed;
let redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config, uiHints) : null;
if (redactedRaw && shouldFallbackToStructuredRawRedaction({
redactedRaw,
originalConfig: snapshot.config,
restoreParsed: (parsed) => withRestoreWarningsSuppressed(() => restoreRedactedValues(parsed, snapshot.config, uiHints))
})) redactedRaw = null;
const redactedResolved = redactConfigObject(snapshot.resolved, uiHints);
const { pluginMetadataSnapshot: _pluginMetadataSnapshot, ...publicSnapshot } = snapshot;
return {
...publicSnapshot,
sourceConfig: redactedResolved,
runtimeConfig: redactedConfig,
config: redactedConfig,
raw: redactedRaw,
parsed: redactedParsed,
resolved: redactedResolved
};
}
/**
* Deep-walk `incoming` and replace any {@link REDACTED_SENTINEL} values
* (on sensitive paths) with the corresponding value from `original`.
*
* This is called by config.set / config.apply / config.patch before writing,
* so that credentials survive a Web UI round-trip unmodified.
*/
function restoreRedactedValues(incoming, original, hints) {
if (incoming === null || incoming === void 0) return {
ok: false,
error: "no input"
};
if (typeof incoming !== "object") return {
ok: false,
error: "input not an object"
};
try {
let restored;
if (hints) {
const lookup = buildRedactionLookup(hints);
if (lookup.has("")) restored = restoreRedactedValuesWithLookup(incoming, original, lookup, "", hints);
else restored = restoreRedactedValuesGuessing(incoming, original, "", hints);
} else restored = restoreRedactedValuesGuessing(incoming, original, "");
assertNoRedactedSentinel(restored, "");
return {
ok: true,
result: restored
};
} catch (err) {
if (err instanceof RedactionError) return {
ok: false,
humanReadableMessage: err.humanReadableMessage
};
throw err;
}
}
var RedactionError = class extends Error {
constructor(key, humanReadableMessage) {
super("internal error class---should never escape");
this.key = key;
this.humanReadableMessage = humanReadableMessage ?? `Sentinel value "__OPENCLAW_REDACTED__" in key ${key} is not valid as real data`;
this.name = "RedactionError";
}
};
function restoreOriginalValueOrThrow(params) {
if (params.key in params.original) return params.original[params.key];
if (!suppressRestoreWarnings) log.warn(`Cannot un-redact config key ${params.path} as it doesn't have any value`);
throw new RedactionError(params.path);
}
function assertNoRedactedSentinel(value, path) {
if (typeof value === "string" && value === "__OPENCLAW_REDACTED__") {
const pathLabel = path || "<root>";
throw new RedactionError(pathLabel, `Reserved redaction sentinel "${REDACTED_SENTINEL}" is not valid config data (${pathLabel}).`);
}
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const nextPath = path ? `${path}[${index}]` : `[${index}]`;
assertNoRedactedSentinel(value[index], nextPath);
}
return;
}
if (isRecord(value)) for (const [key, item] of Object.entries(value)) assertNoRedactedSentinel(item, path ? `${path}.${key}` : key);
}
function maybeRestoreSecretRefId(params) {
const incomingObj = toObjectRecord(params.incoming);
if (!isSecretRefShape(incomingObj) || incomingObj.id !== "__OPENCLAW_REDACTED__") return { handled: false };
const originalObj = toObjectRecord(params.original);
if (!isSecretRefWithProvider(originalObj)) {
if (isSecretRefShape(originalObj)) throw new RedactionError(params.path, `SecretRef at ${params.path} requires a provider field to restore the redacted id automatically (original ref lacks provider).`);
throw new RedactionError(params.path, `SecretRef at ${params.path} contains a redacted id placeholder with no matching original value.`);
}
if (!isSecretRefWithProvider(incomingObj)) throw new RedactionError(params.path, `SecretRef at ${params.path} must include source, provider, and id when redacted placeholders are present.`);
if (incomingObj.source !== originalObj.source || incomingObj.provider !== originalObj.provider) throw new RedactionError(params.path, `SecretRef at ${params.path} changed source/provider while id is redacted. Provide an explicit id when changing source/provider.`);
return {
handled: true,
value: {
...incomingObj,
id: originalObj.id
}
};
}
function mapRedactedArray(params) {
const originalArray = Array.isArray(params.original) ? params.original : [];
if (params.incoming.length < originalArray.length) log.warn(`Redacted config array key ${params.path} has been truncated`);
return params.incoming.map((item, index) => params.mapItem(item, index, originalArray));
}
function toObjectRecord(value) {
return isRecord(value) ? value : {};
}
function shouldPassThroughRestoreValue(incoming) {
return incoming === null || incoming === void 0 || typeof incoming !== "object";
}
function toRestoreArrayContext(incoming, prefix) {
if (!Array.isArray(incoming)) return null;
return {
incoming,
path: `${prefix}[]`
};
}
function restoreArrayItemWithLookup(params) {
if (params.item === "__OPENCLAW_REDACTED__") return params.originalArray[params.index];
return restoreRedactedValuesWithLookup(params.item, params.originalArray[params.index], params.lookup, params.path, params.hints);
}
function restoreArrayItemWithGuessing(params) {
if (!isExplicitlyNonSensitivePath(params.hints, [params.path]) && isSensitivePath(params.path) && params.item === "__OPENCLAW_REDACTED__") return params.originalArray[params.index];
return restoreRedactedValuesGuessing(params.item, params.originalArray[params.index], params.path, params.hints);
}
function restoreGuessingArray(incoming, original, path, hints) {
return mapRedactedArray({
incoming,
original,
path,
mapItem: (item, index, originalArray) => restoreArrayItemWithGuessing({
item,
index,
originalArray,
path,
hints
})
});
}
function shouldRestoreSensitiveGuessingPath(path, hintPaths, hints) {
return !isExplicitlyNonSensitivePath(hints, hintPaths) && (isSensitivePath(path) || hasSensitiveUrlHintPath(hints, hintPaths) || isSensitiveUrlPath(path));
}
function restoreRedactedEntryGuessing(params) {
const hintPaths = [params.path, params.wildcardPath];
const canRestoreSecretRef = shouldRestoreSensitiveGuessingPath(params.path, hintPaths, params.hints);
if (params.value === "__OPENCLAW_REDACTED__" && canRestoreSecretRef) return restoreOriginalValueOrThrow({
key: params.key,
path: params.path,
original: params.original
});
if (typeof params.value === "object" && params.value !== null) {
if (canRestoreSecretRef) {
const restoredSecretRef = maybeRestoreSecretRefId({
incoming: params.value,
original: params.original[params.key],
path: params.path
});
if (restoredSecretRef.handled) return restoredSecretRef.value;
}
return restoreRedactedValuesGuessing(params.value, params.original[params.key], params.path, params.hints);
}
return params.value;
}
/**
* Worker for restoreRedactedValues().
* Used when there are ConfigUiHints available.
*/
function restoreRedactedValuesWithLookup(incoming, original, lookup, prefix, hints) {
if (shouldPassThroughRestoreValue(incoming)) return incoming;
const arrayContext = toRestoreArrayContext(incoming, prefix);
if (arrayContext) {
const { incoming: incomingArray, path } = arrayContext;
if (!lookup.has(path)) return restoreRedactedValuesGuessing(incomingArray, original, prefix, hints);
return mapRedactedArray({
incoming: incomingArray,
original,
path,
mapItem: (item, index, originalArray) => restoreArrayItemWithLookup({
item,
index,
originalArray,
lookup,
path,
hints
})
});
}
const orig = toObjectRecord(original);
const result = {};
for (const [key, value] of Object.entries(toObjectRecord(incoming))) {
result[key] = value;
const path = prefix ? `${prefix}.${key}` : key;
const wildcardPath = prefix ? `${prefix}.*` : "*";
let matched = false;
for (const candidate of [path, wildcardPath]) if (lookup.has(candidate)) {
matched = true;
if (value === "__OPENCLAW_REDACTED__" && (hints[candidate]?.sensitive === true || hasSensitiveUrlHintPath(hints, [
candidate,
path,
wildcardPath
]) || isSensitiveUrlPath(path))) result[key] = restoreOriginalValueOrThrow({
key,
path: candidate,
original: orig
});
else if (typeof value === "object" && value !== null) {
const restoredSecretRef = maybeRestoreSecretRefId({
incoming: value,
original: orig[key],
path
});
result[key] = restoredSecretRef.handled ? restoredSecretRef.value : restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints);
}
break;
}
if (!matched) result[key] = restoreRedactedEntryGuessing({
key,
value,
path,
wildcardPath,
original: orig,
hints
});
}
return result;
}
/**
* Worker for restoreRedactedValues().
* Used when ConfigUiHints are NOT available.
*/
function restoreRedactedValuesGuessing(incoming, original, prefix, hints) {
if (shouldPassThroughRestoreValue(incoming)) return incoming;
const arrayContext = toRestoreArrayContext(incoming, prefix);
if (arrayContext) {
const { incoming: incomingArray, path } = arrayContext;
return restoreGuessingArray(incomingArray, original, path, hints);
}
const orig = toObjectRecord(original);
const result = {};
for (const [key, value] of Object.entries(toObjectRecord(incoming))) result[key] = restoreRedactedEntryGuessing({
key,
value,
path: prefix ? `${prefix}.${key}` : key,
wildcardPath: prefix ? `${prefix}.*` : "*",
original: orig,
hints
});
return result;
}
//#endregion
export { isSensitiveConfigPath as a, restoreRedactedValues as i, redactConfigObject as n, redactConfigSnapshot as r, REDACTED_SENTINEL as t };