From 23f434f98d0fc24d12a74b923a6228c224e6ecb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 1 Mar 2026 23:45:29 +0000 Subject: [PATCH] fix(skills): constrain plugin skill paths --- src/agents/skills/plugin-skills.test.ts | 72 +++++++++++++++++++++++++ src/agents/skills/plugin-skills.ts | 5 ++ 2 files changed, 77 insertions(+) diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index 4747d59bf5c..86a49080256 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -100,4 +100,76 @@ describe("resolvePluginSkillDirs", () => { expect(dirs).toEqual([path.resolve(helperRoot, "skills")]); }); + + it("rejects plugin skill paths that escape the plugin root", async () => { + const workspaceDir = await tempDirs.make("openclaw-"); + const pluginRoot = await tempDirs.make("openclaw-plugin-"); + const outsideDir = await tempDirs.make("openclaw-outside-"); + const outsideSkills = path.join(outsideDir, "skills"); + await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true }); + await fs.mkdir(outsideSkills, { recursive: true }); + const escapePath = path.relative(pluginRoot, outsideSkills); + + hoisted.loadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "helper", + name: "Helper", + channels: [], + providers: [], + skills: ["./skills", escapePath], + origin: "workspace", + rootDir: pluginRoot, + source: pluginRoot, + manifestPath: path.join(pluginRoot, "openclaw.plugin.json"), + }, + ], + } satisfies PluginManifestRegistry); + + const dirs = resolvePluginSkillDirs({ + workspaceDir, + config: {} as OpenClawConfig, + }); + + expect(dirs).toEqual([path.resolve(pluginRoot, "skills")]); + }); + + it("rejects plugin skill symlinks that resolve outside plugin root", async () => { + const workspaceDir = await tempDirs.make("openclaw-"); + const pluginRoot = await tempDirs.make("openclaw-plugin-"); + const outsideDir = await tempDirs.make("openclaw-outside-"); + const outsideSkills = path.join(outsideDir, "skills"); + const linkPath = path.join(pluginRoot, "skills-link"); + await fs.mkdir(outsideSkills, { recursive: true }); + await fs.symlink( + outsideSkills, + linkPath, + process.platform === "win32" ? ("junction" as const) : ("dir" as const), + ); + + hoisted.loadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "helper", + name: "Helper", + channels: [], + providers: [], + skills: ["./skills-link"], + origin: "workspace", + rootDir: pluginRoot, + source: pluginRoot, + manifestPath: path.join(pluginRoot, "openclaw.plugin.json"), + }, + ], + } satisfies PluginManifestRegistry); + + const dirs = resolvePluginSkillDirs({ + workspaceDir, + config: {} as OpenClawConfig, + }); + + expect(dirs).toEqual([]); + }); }); diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 594bfcdabb3..5a02737e5cd 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -8,6 +8,7 @@ import { resolveMemorySlotDecision, } from "../../plugins/config-state.js"; import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; +import { isPathInsideWithRealpath } from "../../security/scan-paths.js"; const log = createSubsystemLogger("skills"); @@ -72,6 +73,10 @@ export function resolvePluginSkillDirs(params: { log.warn(`plugin skill path not found (${record.id}): ${candidate}`); continue; } + if (!isPathInsideWithRealpath(record.rootDir, candidate, { requireRealpath: true })) { + log.warn(`plugin skill path escapes plugin root (${record.id}): ${candidate}`); + continue; + } if (seen.has(candidate)) { continue; }