fix(skills): validate installer metadata specs
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user