import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { CircularIncludeError, ConfigIncludeError, MAX_INCLUDE_FILE_BYTES, deepMerge, type IncludeResolver, resolveConfigIncludes, } from "./includes.js"; const ROOT_DIR = path.parse(process.cwd()).root; const CONFIG_DIR = path.join(ROOT_DIR, "config"); const ETC_OPENCLAW_DIR = path.join(ROOT_DIR, "etc", "openclaw"); const SHARED_DIR = path.join(ROOT_DIR, "shared"); const DEFAULT_BASE_PATH = path.join(CONFIG_DIR, "openclaw.json"); function configPath(...parts: string[]) { return path.join(CONFIG_DIR, ...parts); } function etcOpenClawPath(...parts: string[]) { return path.join(ETC_OPENCLAW_DIR, ...parts); } function sharedPath(...parts: string[]) { return path.join(SHARED_DIR, ...parts); } function createMockResolver(files: Record): IncludeResolver { return { readFile: (filePath: string) => { if (filePath in files) { return JSON.stringify(files[filePath]); } throw new Error(`ENOENT: no such file: ${filePath}`); }, parseJson: JSON.parse, }; } function resolve(obj: unknown, files: Record = {}, basePath = DEFAULT_BASE_PATH) { return resolveConfigIncludes(obj, basePath, createMockResolver(files)); } function expectResolveIncludeError( run: () => unknown, expectedPattern?: RegExp, ): ConfigIncludeError { let thrown: unknown; try { run(); } catch (error) { thrown = error; } expect(thrown).toBeInstanceOf(ConfigIncludeError); if (expectedPattern) { expect((thrown as Error).message).toMatch(expectedPattern); } return thrown as ConfigIncludeError; } describe("resolveConfigIncludes", () => { it.each([ { name: "string", value: "hello", expected: "hello" }, { name: "number", value: 42, expected: 42 }, { name: "boolean", value: true, expected: true }, { name: "null", value: null, expected: null }, { name: "array", value: [1, 2, { a: 1 }], expected: [1, 2, { a: 1 }] }, { name: "nested object", value: { foo: "bar", nested: { x: 1 } }, expected: { foo: "bar", nested: { x: 1 } }, }, ] as const)("passes through non-include $name values unchanged", ({ value, expected }) => { expect(resolve(value)).toEqual(expected); }); it("rejects absolute path outside config directory (CWE-22)", () => { const absolute = etcOpenClawPath("agents.json"); const files = { [absolute]: { list: [{ id: "main" }] } }; const obj = { agents: { $include: absolute } }; expectResolveIncludeError(() => resolve(obj, files), /escapes config directory/); }); it.each([ { name: "single file include", files: { [configPath("agents.json")]: { list: [{ id: "main" }] } }, obj: { agents: { $include: "./agents.json" } }, expected: { agents: { list: [{ id: "main" }] }, }, }, { name: "array include deep merge", files: { [configPath("a.json")]: { "group-a": ["agent1"] }, [configPath("b.json")]: { "group-b": ["agent2"] }, }, obj: { broadcast: { $include: ["./a.json", "./b.json"] } }, expected: { broadcast: { "group-a": ["agent1"], "group-b": ["agent2"], }, }, }, { name: "array include overlapping keys", files: { [configPath("a.json")]: { agents: { defaults: { workspace: "~/a" } } }, [configPath("b.json")]: { agents: { list: [{ id: "main" }] } }, }, obj: { $include: ["./a.json", "./b.json"] }, expected: { agents: { defaults: { workspace: "~/a" }, list: [{ id: "main" }], }, }, }, ] as const)("resolves include merges: $name", ({ obj, files, expected }) => { expect(resolve(obj, files)).toEqual(expected); }); it.each([ { name: "adds sibling keys after include", obj: { $include: "./base.json", c: 3 }, expected: { a: 1, b: 2, c: 3 }, }, { name: "lets siblings override included keys", obj: { $include: "./base.json", b: 99 }, expected: { a: 1, b: 99 }, }, ] as const)("merges include content with sibling keys: $name", ({ obj, expected }) => { const files = { [configPath("base.json")]: { a: 1, b: 2 } }; expect(resolve(obj, files)).toEqual(expected); }); it.each([ { includeFile: "list.json", included: ["a", "b"] }, { includeFile: "value.json", included: "hello" }, ] as const)( "throws when sibling keys are used with non-object include $includeFile", ({ includeFile, included }) => { const files = { [configPath(includeFile)]: included }; const obj = { $include: `./${includeFile}`, extra: true }; expectResolveIncludeError( () => resolve(obj, files), /Sibling keys require included content to be an object/, ); }, ); it("resolves nested includes", () => { const files = { [configPath("level1.json")]: { nested: { $include: "./level2.json" } }, [configPath("level2.json")]: { deep: "value" }, }; const obj = { $include: "./level1.json" }; expect(resolve(obj, files)).toEqual({ nested: { deep: "value" }, }); }); it.each([ { name: "read failures", run: () => resolve({ $include: "./missing.json" }), pattern: /Failed to read include file/, }, { name: "parse failures", run: () => resolveConfigIncludes({ $include: "./bad.json" }, DEFAULT_BASE_PATH, { readFile: () => "{ invalid json }", parseJson: JSON.parse, }), pattern: /Failed to parse include file/, }, ] as const)("surfaces include $name", ({ run, pattern }) => { expectResolveIncludeError(run, pattern); }); it("throws CircularIncludeError for circular includes", () => { const aPath = configPath("a.json"); const bPath = configPath("b.json"); const resolver: IncludeResolver = { readFile: (filePath: string) => { if (filePath === aPath) { return JSON.stringify({ $include: "./b.json" }); } if (filePath === bPath) { return JSON.stringify({ $include: "./a.json" }); } throw new Error(`Unknown file: ${filePath}`); }, parseJson: JSON.parse, }; const obj = { $include: "./a.json" }; try { resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver); throw new Error("expected circular include error"); } catch (err) { expect(err).toBeInstanceOf(CircularIncludeError); const circular = err as CircularIncludeError; expect(circular.chain).toEqual(expect.arrayContaining([DEFAULT_BASE_PATH, aPath, bPath])); expect(circular.message).toMatch(/Circular include detected/); expect(circular.message).toContain("a.json"); expect(circular.message).toContain("b.json"); } }); it.each([ { name: "rejects scalar include value", obj: { $include: 123 }, expectedPattern: /expected string or array/, }, { name: "rejects number in include array", obj: { $include: ["./valid.json", 123] }, expectedPattern: /expected string, got number/, }, { name: "rejects null in include array", obj: { $include: ["./valid.json", null] }, expectedPattern: /expected string, got object/, }, { name: "rejects boolean in include array", obj: { $include: ["./valid.json", false] }, expectedPattern: /expected string, got boolean/, }, ] as const)("throws on invalid include value/item types: $name", ({ obj, expectedPattern }) => { const files = { [configPath("valid.json")]: { valid: true } }; expectResolveIncludeError(() => resolve(obj, files), expectedPattern); }); it("respects max depth limit", () => { const files: Record = {}; for (let i = 0; i < 15; i++) { files[configPath(`level${i}.json`)] = { $include: `./level${i + 1}.json`, }; } files[configPath("level15.json")] = { done: true }; const obj = { $include: "./level0.json" }; expectResolveIncludeError(() => resolve(obj, files), /Maximum include depth/); }); it("allows depth 10 but rejects depth 11", () => { const okFiles: Record = {}; for (let i = 0; i < 9; i++) { okFiles[configPath(`ok${i}.json`)] = { $include: `./ok${i + 1}.json` }; } okFiles[configPath("ok9.json")] = { done: true }; expect(resolve({ $include: "./ok0.json" }, okFiles)).toEqual({ done: true, }); const failFiles: Record = {}; for (let i = 0; i < 10; i++) { failFiles[configPath(`fail${i}.json`)] = { $include: `./fail${i + 1}.json`, }; } failFiles[configPath("fail10.json")] = { done: true }; expectResolveIncludeError( () => resolve({ $include: "./fail0.json" }, failFiles), /Maximum include depth/, ); }); it.each([ { name: "resolves nested relative file path", files: { [configPath("clients", "mueller", "agents.json")]: { id: "mueller" }, }, obj: { agent: { $include: "./clients/mueller/agents.json" } }, expected: { agent: { id: "mueller" }, }, }, { name: "preserves nested override ordering", files: { [configPath("base.json")]: { nested: { $include: "./nested.json" } }, [configPath("nested.json")]: { a: 1, b: 2 }, }, obj: { $include: "./base.json", nested: { b: 9 } }, expected: { nested: { a: 1, b: 9 }, }, }, ] as const)( "handles relative paths and nested include ordering: $name", ({ obj, files, expected }) => { expect(resolve(obj, files)).toEqual(expected); }, ); it("enforces traversal boundaries while allowing safe nested-parent paths", () => { expectResolveIncludeError( () => resolve( { $include: "../../shared/common.json" }, { [sharedPath("common.json")]: { shared: true } }, configPath("sub", "openclaw.json"), ), /escapes config directory/, ); expect( resolve( { $include: "./sub/child.json" }, { [configPath("sub", "child.json")]: { $include: "../shared/common.json" }, [configPath("shared", "common.json")]: { shared: true }, }, ), ).toEqual({ shared: true, }); }); }); describe("real-world config patterns", () => { it.each([ { name: "per-client agent includes", files: { [configPath("clients", "mueller.json")]: { agents: [ { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot", }, { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe", }, ], broadcast: { "group-mueller": ["mueller-screenshot", "mueller-transcribe"], }, }, [configPath("clients", "schmidt.json")]: { agents: [ { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot", }, ], broadcast: { "group-schmidt": ["schmidt-screenshot"] }, }, }, obj: { gateway: { port: 18789 }, $include: ["./clients/mueller.json", "./clients/schmidt.json"], }, expected: { gateway: { port: 18789 }, agents: [ { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" }, { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" }, ], broadcast: { "group-mueller": ["mueller-screenshot", "mueller-transcribe"], "group-schmidt": ["schmidt-screenshot"], }, }, }, { name: "modular config structure", files: { [configPath("gateway.json")]: { gateway: { port: 18789, bind: "loopback" }, }, [configPath("channels", "whatsapp.json")]: { channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } }, }, [configPath("agents", "defaults.json")]: { agents: { defaults: { sandbox: { mode: "all" } } }, }, }, obj: { $include: ["./gateway.json", "./channels/whatsapp.json", "./agents/defaults.json"], }, expected: { gateway: { port: 18789, bind: "loopback" }, channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } }, agents: { defaults: { sandbox: { mode: "all" } } }, }, }, ] as const)("supports common modular include layouts: $name", ({ obj, files, expected }) => { expect(resolve(obj, files)).toEqual(expected); }); }); describe("security: path traversal protection (CWE-22)", () => { function expectRejectedTraversalPaths( cases: ReadonlyArray<{ includePath: string; expectEscapesMessage: boolean }>, ) { for (const { includePath, expectEscapesMessage } of cases) { const obj = { $include: includePath }; expect(() => resolve(obj, {}), includePath).toThrow(ConfigIncludeError); if (expectEscapesMessage) { expect(() => resolve(obj, {}), includePath).toThrow(/escapes config directory/); } } } describe("absolute path attacks", () => { it("rejects absolute path attack variants", () => { const cases = [ { includePath: "/etc/passwd", expectEscapesMessage: true }, { includePath: "/etc/shadow", expectEscapesMessage: true }, { includePath: `${process.env.HOME}/.ssh/id_rsa`, expectEscapesMessage: false }, { includePath: "/tmp/malicious.json", expectEscapesMessage: false }, { includePath: "/", expectEscapesMessage: false }, ] as const; expectRejectedTraversalPaths(cases); }); }); describe("relative traversal attacks", () => { it("rejects relative traversal path variants", () => { const cases = [ { includePath: "../../etc/passwd", expectEscapesMessage: true }, { includePath: "../../../etc/shadow", expectEscapesMessage: false }, { includePath: "../../../../../../../../etc/passwd", expectEscapesMessage: false }, { includePath: "../sibling-dir/secret.json", expectEscapesMessage: false }, { includePath: "/config/../../../etc/passwd", expectEscapesMessage: false }, ] as const; expectRejectedTraversalPaths(cases); }); }); describe("legitimate includes (should work)", () => { it.each([ { name: "same-directory with ./ prefix", includePath: "./sub.json", files: { [configPath("sub.json")]: { key: "value" } }, expected: { key: "value" }, }, { name: "same-directory without ./ prefix", includePath: "sub.json", files: { [configPath("sub.json")]: { key: "value" } }, expected: { key: "value" }, }, { name: "subdirectory", includePath: "./sub/nested.json", files: { [configPath("sub", "nested.json")]: { nested: true } }, expected: { nested: true }, }, { name: "deep subdirectory", includePath: "./a/b/c/deep.json", files: { [configPath("a", "b", "c", "deep.json")]: { deep: true } }, expected: { deep: true }, }, ] as const)( "allows legitimate include path under config root: $name", ({ includePath, files, expected }) => { const obj = { $include: includePath }; expect(resolve(obj, files)).toEqual(expected); }, ); // Note: Upward traversal from nested configs is restricted for security. // Each config file can only include files from its own directory and subdirectories. // This prevents potential path traversal attacks even in complex nested scenarios. }); describe("error properties", () => { it.each([ { includePath: "/etc/passwd", expectedMessageIncludes: ["escapes config directory", "/etc/passwd"], }, { includePath: "/etc/shadow", expectedMessageIncludes: ["/etc/shadow"], }, { includePath: "../../etc/passwd", expectedMessageIncludes: ["escapes config directory", "../../etc/passwd"], }, ] as const)( "preserves error type/path/message details for $includePath", ({ includePath, expectedMessageIncludes }) => { const obj = { $include: includePath }; try { resolve(obj, {}); expect.fail("Should have thrown"); } catch (err) { expect(err, includePath).toBeInstanceOf(ConfigIncludeError); expect(err, includePath).toHaveProperty("name", "ConfigIncludeError"); expect((err as ConfigIncludeError).includePath, includePath).toBe(includePath); for (const messagePart of expectedMessageIncludes) { expect((err as Error).message, `${includePath}: ${messagePart}`).toContain(messagePart); } } }, ); }); describe("array includes with malicious paths", () => { it.each([ { name: "one malicious path", files: { [configPath("good.json")]: { good: true } }, includePaths: ["./good.json", "/etc/passwd"], }, { name: "multiple malicious paths", files: {}, includePaths: ["/etc/passwd", "/etc/shadow"], }, ] as const)("rejects arrays with malicious include paths: $name", ({ includePaths, files }) => { const obj = { $include: includePaths }; expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); }); it("allows array with all legitimate paths", () => { const files = { [configPath("a.json")]: { a: 1 }, [configPath("b.json")]: { b: 2 }, }; const obj = { $include: ["./a.json", "./b.json"] }; expect(resolve(obj, files)).toEqual({ a: 1, b: 2 }); }); }); describe("prototype pollution protection", () => { it("blocks prototype pollution vectors in shallow and nested merges", () => { const cases = [ { base: {}, incoming: JSON.parse('{"__proto__":{"polluted":true}}'), expected: {}, }, { base: { safe: 1 }, incoming: { prototype: { x: 1 }, constructor: { y: 2 }, normal: 3 }, expected: { safe: 1, normal: 3 }, }, { base: { nested: { a: 1 } }, incoming: { nested: JSON.parse('{"__proto__":{"polluted":true}}') }, expected: { nested: { a: 1 } }, }, ] as const; for (const { base, incoming, expected } of cases) { const result = deepMerge(base, incoming); expect((Object.prototype as Record).polluted).toBeUndefined(); expect(result).toEqual(expected); } }); }); describe("edge cases", () => { it.each([ { includePath: "./file\x00.json", expectedError: undefined }, { includePath: "//etc/passwd", expectedError: ConfigIncludeError }, ] as const)("rejects malformed include path $includePath", ({ includePath, expectedError }) => { const obj = { $include: includePath }; if (expectedError) { expectResolveIncludeError(() => resolve(obj, {})); return; } // Path with null byte should be rejected or handled safely. expect(() => resolve(obj, {}), includePath).toThrow(); }); it("allows child include when config is at filesystem root", () => { const rootConfigPath = path.join(path.parse(process.cwd()).root, "test.json"); const childPath = path.join(path.parse(process.cwd()).root, "child.json"); const files = { [childPath]: { root: true } }; const obj = { $include: childPath }; expect(resolve(obj, files, rootConfigPath)).toEqual({ root: true }); }); it("allows include files when the config root path is a symlink", async () => { await withTempDir({ prefix: "openclaw-includes-symlink-" }, async (tempRoot) => { const realRoot = path.join(tempRoot, "real"); const linkRoot = path.join(tempRoot, "link"); await fs.mkdir(path.join(realRoot, "includes"), { recursive: true }); await fs.writeFile( path.join(realRoot, "includes", "extra.json5"), "{ logging: { redactSensitive: 'tools' } }\n", "utf-8", ); await fs.symlink(realRoot, linkRoot, process.platform === "win32" ? "junction" : undefined); const result = resolveConfigIncludes( { $include: "./includes/extra.json5" }, path.join(linkRoot, "openclaw.json"), ); expect(result).toEqual({ logging: { redactSensitive: "tools" } }); }); }); it("rejects include files that are hardlinked aliases", async () => { if (process.platform === "win32") { return; } await withTempDir({ prefix: "openclaw-includes-hardlink-" }, async (tempRoot) => { const configDir = path.join(tempRoot, "config"); const outsideDir = path.join(tempRoot, "outside"); await fs.mkdir(configDir, { recursive: true }); await fs.mkdir(outsideDir, { recursive: true }); const includePath = path.join(configDir, "extra.json5"); const outsidePath = path.join(outsideDir, "secret.json5"); await fs.writeFile(outsidePath, '{"logging":{"redactSensitive":"tools"}}\n', "utf-8"); try { await fs.link(outsidePath, includePath); } catch (err) { if ((err as NodeJS.ErrnoException).code === "EXDEV") { return; } throw err; } expect(() => resolveConfigIncludes( { $include: "./extra.json5" }, path.join(configDir, "openclaw.json"), ), ).toThrow(/security checks|hardlink/i); }); }); it("rejects oversized include files", async () => { await withTempDir({ prefix: "openclaw-includes-big-" }, async (tempRoot) => { const configDir = path.join(tempRoot, "config"); await fs.mkdir(configDir, { recursive: true }); const includePath = path.join(configDir, "big.json5"); const payload = "a".repeat(MAX_INCLUDE_FILE_BYTES + 1); await fs.writeFile(includePath, `{"blob":"${payload}"}`, "utf-8"); expect(() => resolveConfigIncludes({ $include: "./big.json5" }, path.join(configDir, "openclaw.json")), ).toThrow(/security checks|max/i); }); }); }); });