fix(skills): validate installer metadata specs

This commit is contained in:
Peter Steinberger
2026-03-01 23:45:25 +00:00
parent 577f2fa540
commit 4614222572
2 changed files with 168 additions and 9 deletions

View File

@@ -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<string, string>) {
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();
});
});

View File

@@ -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;
}