UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

227 lines (226 loc) 9.44 kB
import { i as isPathInside } from "./path-BlG8lhgR.js"; import { i as openRootFileSync, t as canUseRootFileOpen } from "./root-file-jRMCpJW4.js"; import { b as isPlainObject } from "./utils-CCC-BEJH.js"; import { t as isBlockedObjectKey } from "./prototype-keys-D2nJOZIy.js"; import "./boundary-file-read-CBe_wA_B.js"; import { t as parseJsonWithJson5Fallback } from "./parse-json-compat-DvZKmwhP.js"; import "./scan-paths-Bve2UhXh.js"; import fs from "node:fs"; import path from "node:path"; //#region src/config/includes.ts /** * Config includes: $include directive for modular configs * * @example * ```json5 * { * "$include": "./base.json5", // single file * "$include": ["./a.json5", "./b.json5"] // merge multiple * } * ``` */ const INCLUDE_KEY = "$include"; /** Maximum length for $include path and resolved path (CWE-22 hardening). */ const MAX_INCLUDE_PATH_LENGTH = 4096; var ConfigIncludeError = class extends Error { constructor(message, includePath, cause) { super(message); this.includePath = includePath; this.cause = cause; this.name = "ConfigIncludeError"; } }; var CircularIncludeError = class extends ConfigIncludeError { constructor(chain) { super(`Circular include detected: ${chain.join(" -> ")}`, chain[chain.length - 1]); this.chain = chain; this.name = "CircularIncludeError"; } }; /** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */ function deepMerge(target, source) { if (Array.isArray(target) && Array.isArray(source)) return [...target, ...source]; if (isPlainObject(target) && isPlainObject(source)) { const result = { ...target }; for (const key of Object.keys(source)) { if (isBlockedObjectKey(key)) continue; result[key] = key in result ? deepMerge(result[key], source[key]) : source[key]; } return result; } return source; } var IncludeProcessor = class IncludeProcessor { constructor(basePath, resolver, rootDir, allowedRoots) { this.basePath = basePath; this.resolver = resolver; this.visited = /* @__PURE__ */ new Set(); this.depth = 0; this.visited.add(path.normalize(basePath)); const configRootDir = path.normalize(rootDir ?? path.dirname(basePath)); this.configRoot = { rootDir: configRootDir, rootRealDir: path.normalize(safeRealpath(configRootDir)) }; this.allowedRoots = allowedRoots ?? []; } get rootDir() { return this.configRoot.rootDir; } process(obj) { if (Array.isArray(obj)) return obj.map((item) => this.process(item)); if (!isPlainObject(obj)) return obj; if (!("$include" in obj)) return this.processObject(obj); return this.processInclude(obj); } processObject(obj) { const result = {}; for (const [key, value] of Object.entries(obj)) result[key] = this.process(value); return result; } processInclude(obj) { const includeValue = obj[INCLUDE_KEY]; const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY); const included = this.resolveInclude(includeValue); if (otherKeys.length === 0) return included; if (!isPlainObject(included)) throw new ConfigIncludeError("Sibling keys require included content to be an object", typeof includeValue === "string" ? includeValue : INCLUDE_KEY); const rest = {}; for (const key of otherKeys) rest[key] = this.process(obj[key]); return deepMerge(included, rest); } resolveInclude(value) { if (typeof value === "string") return this.loadFile(value); if (Array.isArray(value)) return value.reduce((merged, item) => { if (typeof item !== "string") throw new ConfigIncludeError(`Invalid $include array item: expected string, got ${typeof item}`, String(item)); return deepMerge(merged, this.loadFile(item)); }, {}); throw new ConfigIncludeError(`Invalid $include value: expected string or array of strings, got ${typeof value}`, String(value)); } loadFile(includePath) { const { resolvedPath, root } = this.resolvePath(includePath); this.checkCircular(resolvedPath); this.checkDepth(includePath); const raw = this.readFile(includePath, resolvedPath, root); const parsed = this.parseFile(includePath, resolvedPath, raw); return this.processNested(resolvedPath, parsed); } resolvePath(includePath) { if (includePath.includes("\0")) throw new ConfigIncludeError("Include path must not contain null bytes", includePath); if (includePath.length >= 4096) throw new ConfigIncludeError(`Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`, includePath); const configDir = path.dirname(this.basePath); const resolved = path.isAbsolute(includePath) ? includePath : path.resolve(configDir, includePath); const normalized = path.normalize(resolved); if (normalized.length >= 4096) throw new ConfigIncludeError(`Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`, includePath); const lexicalMatch = this.findContainingRoot(normalized, "rootDir"); if (!lexicalMatch) throw new ConfigIncludeError(`Include path escapes config directory: ${includePath} (root: ${this.rootDir})`, includePath); try { const real = fs.realpathSync(normalized); const realMatch = this.findContainingRoot(real, "rootRealDir"); if (!realMatch) throw new ConfigIncludeError(`Include path resolves outside config directory (symlink): ${includePath} (root: ${this.rootDir})`, includePath); return { resolvedPath: normalized, root: realMatch }; } catch (err) { if (err instanceof ConfigIncludeError) throw err; if (isNotFoundError(err)) return { resolvedPath: normalized, root: lexicalMatch }; throw new ConfigIncludeError(`Failed to resolve include file realpath: ${includePath} (resolved: ${normalized})`, includePath, err instanceof Error ? err : void 0); } } findContainingRoot(candidate, field) { if (isPathInside(this.configRoot[field], candidate)) return this.configRoot; for (const root of this.allowedRoots) if (isPathInside(root[field], candidate)) return root; return null; } checkCircular(resolvedPath) { if (this.visited.has(resolvedPath)) throw new CircularIncludeError([...this.visited, resolvedPath]); } checkDepth(includePath) { if (this.depth >= 10) throw new ConfigIncludeError(`Maximum include depth (10) exceeded at: ${includePath}`, includePath); } readFile(includePath, resolvedPath, root) { try { if (this.resolver.readFileWithGuards) return this.resolver.readFileWithGuards({ includePath, resolvedPath, rootRealDir: root.rootRealDir }); return this.resolver.readFile(resolvedPath); } catch (err) { if (err instanceof ConfigIncludeError) throw err; throw new ConfigIncludeError(`Failed to read include file: ${includePath} (resolved: ${resolvedPath})`, includePath, err instanceof Error ? err : void 0); } } parseFile(includePath, resolvedPath, raw) { try { return this.resolver.parseJson(raw); } catch (err) { throw new ConfigIncludeError(`Failed to parse include file: ${includePath} (resolved: ${resolvedPath})`, includePath, err instanceof Error ? err : void 0); } } processNested(resolvedPath, parsed) { const nested = new IncludeProcessor(resolvedPath, this.resolver, this.rootDir, this.allowedRoots); nested.visited = new Set([...this.visited, resolvedPath]); nested.depth = this.depth + 1; return nested.process(parsed); } }; function safeRealpath(target) { try { return fs.realpathSync(target); } catch { return target; } } function isNotFoundError(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } function readConfigIncludeFileWithGuards(params) { const ioFs = params.ioFs ?? fs; const maxBytes = params.maxBytes ?? 2097152; if (!canUseRootFileOpen(ioFs)) return ioFs.readFileSync(params.resolvedPath, "utf-8"); const opened = openRootFileSync({ absolutePath: params.resolvedPath, rootPath: params.rootRealDir, rootRealPath: params.rootRealDir, boundaryLabel: "config directory", skipLexicalRootCheck: true, maxBytes, ioFs }); if (!opened.ok) { if (opened.reason === "validation") throw new ConfigIncludeError(`Include file failed security checks (regular file, max ${maxBytes} bytes, no hardlinks): ${params.includePath}`, params.includePath); throw new ConfigIncludeError(`Failed to read include file: ${params.includePath} (resolved: ${params.resolvedPath})`, params.includePath, opened.error instanceof Error ? opened.error : void 0); } try { return ioFs.readFileSync(opened.fd, "utf-8"); } finally { ioFs.closeSync(opened.fd); } } const defaultResolver = { readFile: (p) => fs.readFileSync(p, "utf-8"), readFileWithGuards: ({ includePath, resolvedPath, rootRealDir }) => readConfigIncludeFileWithGuards({ includePath, resolvedPath, rootRealDir }), parseJson: parseJsonWithJson5Fallback }; /** * Resolves all $include directives in a parsed config object. */ function resolveConfigIncludes(obj, configPath, resolver = defaultResolver, options = {}) { return new IncludeProcessor(configPath, resolver, void 0, (options.allowedRoots ?? []).filter((entry) => typeof entry === "string" && entry.length > 0 && path.isAbsolute(entry)).map((entry) => { const rootDir = path.normalize(entry); return { rootDir, rootRealDir: path.normalize(safeRealpath(rootDir)) }; })).process(obj); } //#endregion export { resolveConfigIncludes as a, readConfigIncludeFileWithGuards as i, ConfigIncludeError as n, INCLUDE_KEY as r, CircularIncludeError as t };