Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions convex/httpApiV1.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6656,6 +6656,167 @@ describe("httpApiV1 handlers", () => {
});
});

it("plugin verify endpoint returns version-scoped trust and provenance evidence", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args && !("version" in args)) {
return {
package: {
_id: "packages:demo-plugin",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
tags: { latest: "packageReleases:1" },
latestReleaseId: "packageReleases:1",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: { _id: "users:demo", handle: "demo", displayName: "Demo Publisher" },
};
}
if ("packageId" in args && "version" in args) {
return {
_id: "packageReleases:1",
packageId: "packages:demo-plugin",
version: "1.0.0",
createdAt: 1,
changelog: "Initial release",
distTags: ["latest"],
files: [
{
path: "openclaw.plugin.json",
size: 100,
sha256: "f".repeat(64),
storageId: "storage:manifest",
contentType: "application/json",
},
],
artifactKind: "npm-pack",
clawpackSha256: "c".repeat(64),
clawpackSize: 123,
clawpackFormat: "tgz",
npmIntegrity: "sha512-demo",
npmShasum: "d".repeat(40),
npmTarballName: "demo-plugin-1.0.0.tgz",
verification: {
tier: "source-linked",
scope: "artifact-only",
sourceRepo: "demo/plugin",
sourceCommit: "abc123",
sourceTag: "v1.0.0",
hasProvenance: true,
scanStatus: "clean",
},
compatibility: {
pluginApiRange: ">=2026.3.24",
builtWithOpenClawVersion: "2026.5.24",
},
capabilities: {
executesCode: true,
hooks: ["before_dispatch"],
capabilityTags: ["security", "hook:before-dispatch"],
},
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "No static findings.",
engineVersion: "1",
checkedAt: 1,
},
vtAnalysis: {
status: "clean",
verdict: "benign",
checkedAt: 1,
},
};
}
if ("packageId" in args) {
return {
_id: "packageTrustedPublishers:1",
packageId: "packages:demo-plugin",
provider: "github-actions",
repository: "demo/plugin",
repositoryId: "123",
repositoryOwner: "demo",
repositoryOwnerId: "456",
workflowFilename: "publish.yml",
createdAt: 1,
updatedAt: 2,
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());

const response = await __handlers.pluginsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins/demo-plugin/verify?version=1.0.0"),
);

if (response.status !== 200) throw new Error(await response.text());
await expect(response.json()).resolves.toMatchObject({
schema: "clawhub.plugin.verify.v1",
ok: true,
decision: "pass",
reasons: [],
name: "demo-plugin",
family: "code-plugin",
publisherHandle: "demo",
version: "1.0.0",
resolvedFrom: "version",
review: {
status: "unreviewed-community",
isOfficial: false,
channel: "community",
},
artifact: {
kind: "npm-pack",
sha256: "c".repeat(64),
npmIntegrity: "sha512-demo",
files: [{ path: "openclaw.plugin.json", sha256: "f".repeat(64) }],
},
provenance: {
tier: "source-linked",
scope: "artifact-only",
sourceRepo: "demo/plugin",
sourceCommit: "abc123",
hasProvenance: true,
source: "source-linked-release",
trustedPublisher: {
provider: "github-actions",
repository: "demo/plugin",
workflowFilename: "publish.yml",
},
},
security: {
status: "clean",
blockedFromDownload: false,
pending: false,
stale: false,
signals: {
staticScan: {
status: "clean",
engineVersion: "1",
},
virusTotal: {
status: "clean",
verdict: "benign",
},
},
},
compatibility: {
pluginApiRange: ">=2026.3.24",
},
capabilities: {
hooks: ["before_dispatch"],
},
verificationUrl: "https://example.com/api/v1/plugins/demo-plugin/verify?version=1.0.0",
});
});

it("package security endpoint includes package-level public download blocks", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args && "version" in args) {
Expand Down
198 changes: 198 additions & 0 deletions convex/httpApiV1/packagesV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,156 @@ function toPackageReleaseSecurityResponse(params: {
};
}

type PackageVerifyResolvedFrom = "latest" | "version" | "tag";

function buildPackageVerifyUrl(request: Request, packageName: string, version: string) {
const packagePath = encodePackagePath(packageName);
const url = new URL(`/api/v1/plugins/${packagePath}/verify`, publicApiOrigin(request));
url.searchParams.set("version", version);
return url.toString();
}

function resolvePackageVerifySelection(request: Request) {
const url = new URL(request.url);
const versionParam = url.searchParams.get("version")?.trim();
const tagParam = url.searchParams.get("tag")?.trim();
if (versionParam && tagParam) return { error: "Use either version or tag" };
return {
versionParam,
tagParam,
resolvedFrom: (versionParam
? "version"
: tagParam
? "tag"
: "latest") as PackageVerifyResolvedFrom,
};
}

function buildPackageVerifyReasons(params: {
blockedFromDownload: boolean;
scanStatus: "clean" | "suspicious" | "malicious" | "pending" | "not-run";
stale: boolean;
}) {
const reasons: string[] = [];
if (params.blockedFromDownload) reasons.push("security.blocked_from_download");
if (params.scanStatus !== "clean") reasons.push(`security.status_${params.scanStatus}`);
if (params.stale) reasons.push("security.stale");
return reasons;
}

function toPackageVerifyResponse(params: {
request: Request;
pkg: PublicPackageDocLike;
owner: { handle?: string; displayName?: string } | null;
release: ReleaseLike;
trustedPublisher: PackageTrustedPublisherLike | null;
resolvedFrom: PackageVerifyResolvedFrom;
tag: string | null;
}) {
const scanStatus = resolvePackageReleaseScanStatus(params.release);
const trust = toPackageReleaseSecurityResponse({
pkg: params.pkg,
release: params.release,
}).trust;
const reasons = buildPackageVerifyReasons({
blockedFromDownload: trust.blockedFromDownload,
scanStatus,
stale: trust.stale,
});
const artifact = toReleaseArtifact(params.release, params.pkg.name);
const verification = params.release.verification ?? params.pkg.verification ?? null;
const ownerHandle = params.owner?.handle ?? null;
const sourceRepo = verification?.sourceRepo ?? null;
const sourceCommit = verification?.sourceCommit ?? null;
const hasProvenance = verification?.hasProvenance === true;

return {
schema: "clawhub.plugin.verify.v1",
ok: reasons.length === 0,
decision: reasons.length === 0 ? "pass" : "fail",
reasons,
name: params.pkg.name,
displayName: params.pkg.displayName,
family: params.pkg.family,
pageUrl: `https://clawhub.ai/plugins/${encodePackagePath(params.pkg.name)}`,
publisherHandle: ownerHandle,
publisherDisplayName: params.owner?.displayName ?? null,
publisherProfileUrl: ownerHandle ? `https://clawhub.ai/user/${ownerHandle}` : null,
version: params.release.version,
resolvedFrom: params.resolvedFrom,
tag: params.tag,
createdAt: params.release.createdAt,
review: {
status: params.pkg.isOfficial ? "official" : "unreviewed-community",
isOfficial: params.pkg.isOfficial,
channel: params.pkg.channel,
},
artifact: {
...artifact,
files: params.release.files.map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType ?? null,
})),
},
provenance: {
tier: verification?.tier ?? null,
scope: verification?.scope ?? null,
summary: verification?.summary ?? null,
sourceRepo,
sourceCommit,
sourceTag: verification?.sourceTag ?? null,
hasProvenance,
trustedOpenClawPlugin: verification?.trustedOpenClawPlugin === true,
trustedPublisher: toPublicTrustedPublisher(params.trustedPublisher),
source:
sourceRepo && sourceCommit
? "source-linked-release"
: hasProvenance
? "published-package-provenance"
: "unavailable",
},
security: {
status: scanStatus,
moderationState: params.release.manualModeration?.state ?? null,
blockedFromDownload: trust.blockedFromDownload,
pending: trust.pending,
stale: trust.stale,
reasons: trust.reasons,
signals: {
staticScan: params.release.staticScan
? {
status: params.release.staticScan.status,
reasonCodes: params.release.staticScan.reasonCodes ?? [],
checkedAt: params.release.staticScan.checkedAt ?? null,
engineVersion: params.release.staticScan.engineVersion ?? null,
}
: null,
virusTotal: params.release.vtAnalysis
? {
status: params.release.vtAnalysis.status ?? null,
verdict: params.release.vtAnalysis.verdict ?? null,
checkedAt: params.release.vtAnalysis.checkedAt ?? null,
}
: null,
skillSpector: params.release.skillSpectorAnalysis
? {
status: params.release.skillSpectorAnalysis.status ?? null,
checkedAt: params.release.skillSpectorAnalysis.checkedAt ?? null,
}
: null,
},
},
compatibility: params.release.compatibility ?? params.pkg.compatibility ?? null,
capabilities: params.release.capabilities ?? params.pkg.capabilities ?? null,
verificationUrl: buildPackageVerifyUrl(params.request, params.pkg.name, params.release.version),
signature: {
status: "unsigned",
},
};
}

function encodePackagePath(name: string) {
return name
.split("/")
Expand Down Expand Up @@ -3171,6 +3321,54 @@ export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request
pluginFamilies: ["code-plugin", "bundle-plugin"],
});
}

const route = parsePackagePathSegments(segments);
if (route?.rest[0] === "verify" && route.rest.length === 1) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const normalizedPackageName = tryNormalizePackageName(route.packageName);
if (!normalizedPackageName) return text("Plugin not found", 404, rate.headers);
const selection = resolvePackageVerifySelection(request);
if ("error" in selection)
return text(selection.error ?? "Invalid verify request", 400, rate.headers);

const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
const detail = (await runQueryRef(ctx, internalRefs.packages.getByNameForViewerInternal, {
name: normalizedPackageName,
viewerUserId: viewerUserId ?? undefined,
})) as {
package: PublicPackageDocLike | null;
latestRelease: ReleaseLike | null;
owner: { _id: Id<"users">; handle?: string; displayName?: string; image?: string } | null;
} | null;
if (!detail?.package) return text("Plugin not found", 404, rate.headers);
if (detail.package.family !== "code-plugin" && detail.package.family !== "bundle-plugin") {
return text("Package is not a plugin", 404, rate.headers);
}

const release = await getReleaseForRequest(ctx, detail.package, request);
if (!release) return text("Version not found", 404, rate.headers);
const trustedPublisher = await runQueryRef<PackageTrustedPublisherLike | null>(
ctx,
internalRefs.packages.getTrustedPublisherByPackageIdInternal,
{ packageId: detail.package._id },
);

return json(
toPackageVerifyResponse({
request,
pkg: detail.package,
owner: detail.owner,
release,
trustedPublisher,
resolvedFrom: selection.resolvedFrom,
tag: selection.tagParam || null,
}),
200,
rate.headers,
);
}

return text("Not found", 404);
}

Expand Down
Loading
Loading