diff --git a/src/agents/skills/frontmatter.test.ts b/src/agents/skills/frontmatter.test.ts index 280140963..dc7e2fad5 100644 --- a/src/agents/skills/frontmatter.test.ts +++ b/src/agents/skills/frontmatter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveSkillInvocationPolicy } from "./frontmatter.js"; +import { resolveOpenClawMetadata, resolveSkillInvocationPolicy } from "./frontmatter.js"; describe("resolveSkillInvocationPolicy", () => { it("defaults to enabled behaviors", () => { @@ -17,3 +17,51 @@ describe("resolveSkillInvocationPolicy", () => { expect(policy.disableModelInvocation).toBe(true); }); }); + +describe("resolveOpenClawMetadata install validation", () => { + function resolveInstall(frontmatter: Record) { + return resolveOpenClawMetadata(frontmatter)?.install; + } + + it("accepts safe install specs", () => { + const install = resolveInstall({ + metadata: + '{"openclaw":{"install":[{"kind":"brew","formula":"python@3.12"},{"kind":"node","package":"@scope/pkg@1.2.3"},{"kind":"go","module":"example.com/tool/cmd@v1.2.3"},{"kind":"uv","package":"uvicorn[standard]==0.31.0"},{"kind":"download","url":"https://example.com/tool.tar.gz"}]}}', + }); + expect(install).toEqual([ + { kind: "brew", formula: "python@3.12" }, + { kind: "node", package: "@scope/pkg@1.2.3" }, + { kind: "go", module: "example.com/tool/cmd@v1.2.3" }, + { kind: "uv", package: "uvicorn[standard]==0.31.0" }, + { kind: "download", url: "https://example.com/tool.tar.gz" }, + ]); + }); + + it("drops unsafe brew formula values", () => { + const install = resolveInstall({ + metadata: '{"openclaw":{"install":[{"kind":"brew","formula":"wget --HEAD"}]}}', + }); + expect(install).toBeUndefined(); + }); + + it("drops unsafe npm package specs for node installers", () => { + const install = resolveInstall({ + metadata: '{"openclaw":{"install":[{"kind":"node","package":"file:../malicious"}]}}', + }); + expect(install).toBeUndefined(); + }); + + it("drops unsafe go module specs", () => { + const install = resolveInstall({ + metadata: '{"openclaw":{"install":[{"kind":"go","module":"https://evil.example/mod"}]}}', + }); + expect(install).toBeUndefined(); + }); + + it("drops unsafe download urls", () => { + const install = resolveInstall({ + metadata: '{"openclaw":{"install":[{"kind":"download","url":"file:///tmp/payload.tgz"}]}}', + }); + expect(install).toBeUndefined(); + }); +}); diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index 8a5b82171..dd82a7f73 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -1,4 +1,5 @@ import type { Skill } from "@mariozechner/pi-coding-agent"; +import { validateRegistryNpmSpec } from "../../infra/npm-registry-spec.js"; import { parseFrontmatterBlock } from "../../markdown/frontmatter.js"; import { getFrontmatterString, @@ -22,6 +23,90 @@ export function parseFrontmatter(content: string): ParsedSkillFrontmatter { return parseFrontmatterBlock(content); } +const BREW_FORMULA_PATTERN = /^[A-Za-z0-9][A-Za-z0-9@+._/-]*$/; +const GO_MODULE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~+\-/]*(?:@[A-Za-z0-9][A-Za-z0-9._~+\-/]*)?$/; +const UV_PACKAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._\-[\]=<>!~+,]*$/; + +function normalizeSafeBrewFormula(raw: unknown): string | undefined { + if (typeof raw !== "string") { + return undefined; + } + const formula = raw.trim(); + if (!formula || formula.startsWith("-") || formula.includes("\\") || formula.includes("..")) { + return undefined; + } + if (!BREW_FORMULA_PATTERN.test(formula)) { + return undefined; + } + return formula; +} + +function normalizeSafeNpmSpec(raw: unknown): string | undefined { + if (typeof raw !== "string") { + return undefined; + } + const spec = raw.trim(); + if (!spec || spec.startsWith("-")) { + return undefined; + } + if (validateRegistryNpmSpec(spec) !== null) { + return undefined; + } + return spec; +} + +function normalizeSafeGoModule(raw: unknown): string | undefined { + if (typeof raw !== "string") { + return undefined; + } + const moduleSpec = raw.trim(); + if ( + !moduleSpec || + moduleSpec.startsWith("-") || + moduleSpec.includes("\\") || + moduleSpec.includes("://") + ) { + return undefined; + } + if (!GO_MODULE_PATTERN.test(moduleSpec)) { + return undefined; + } + return moduleSpec; +} + +function normalizeSafeUvPackage(raw: unknown): string | undefined { + if (typeof raw !== "string") { + return undefined; + } + const pkg = raw.trim(); + if (!pkg || pkg.startsWith("-") || pkg.includes("\\") || pkg.includes("://")) { + return undefined; + } + if (!UV_PACKAGE_PATTERN.test(pkg)) { + return undefined; + } + return pkg; +} + +function normalizeSafeDownloadUrl(raw: unknown): string | undefined { + if (typeof raw !== "string") { + return undefined; + } + const value = raw.trim(); + if (!value || /\s/.test(value)) { + return undefined; + } + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return undefined; + } + return parsed.toString(); + } catch { + return undefined; + } +} + function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { const parsed = parseOpenClawManifestInstallBase(input, ["brew", "node", "go", "uv", "download"]); if (!parsed) { @@ -45,22 +130,32 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { if (osList.length > 0) { spec.os = osList; } - const formula = typeof raw.formula === "string" ? raw.formula.trim() : ""; + const formula = normalizeSafeBrewFormula(raw.formula); if (formula) { spec.formula = formula; } - const cask = typeof raw.cask === "string" ? raw.cask.trim() : ""; + const cask = normalizeSafeBrewFormula(raw.cask); if (!spec.formula && cask) { spec.formula = cask; } - if (typeof raw.package === "string") { - spec.package = raw.package; + if (spec.kind === "node") { + const pkg = normalizeSafeNpmSpec(raw.package); + if (pkg) { + spec.package = pkg; + } + } else if (spec.kind === "uv") { + const pkg = normalizeSafeUvPackage(raw.package); + if (pkg) { + spec.package = pkg; + } } - if (typeof raw.module === "string") { - spec.module = raw.module; + const moduleSpec = normalizeSafeGoModule(raw.module); + if (moduleSpec) { + spec.module = moduleSpec; } - if (typeof raw.url === "string") { - spec.url = raw.url; + const downloadUrl = normalizeSafeDownloadUrl(raw.url); + if (downloadUrl) { + spec.url = downloadUrl; } if (typeof raw.archive === "string") { spec.archive = raw.archive; @@ -75,6 +170,22 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { spec.targetDir = raw.targetDir; } + if (spec.kind === "brew" && !spec.formula) { + return undefined; + } + if (spec.kind === "node" && !spec.package) { + return undefined; + } + if (spec.kind === "go" && !spec.module) { + return undefined; + } + if (spec.kind === "uv" && !spec.package) { + return undefined; + } + if (spec.kind === "download" && !spec.url) { + return undefined; + } + return spec; }