mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
347773d8c9 |
Vendored
+2
@@ -97,6 +97,7 @@ import type * as lib_skillSearchDigest from "../lib/skillSearchDigest.js";
|
||||
import type * as lib_skillSlugValidator from "../lib/skillSlugValidator.js";
|
||||
import type * as lib_skillStats from "../lib/skillStats.js";
|
||||
import type * as lib_skillSummary from "../lib/skillSummary.js";
|
||||
import type * as lib_skillTrustCard from "../lib/skillTrustCard.js";
|
||||
import type * as lib_skillZip from "../lib/skillZip.js";
|
||||
import type * as lib_skills from "../lib/skills.js";
|
||||
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
|
||||
@@ -230,6 +231,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/skillSlugValidator": typeof lib_skillSlugValidator;
|
||||
"lib/skillStats": typeof lib_skillStats;
|
||||
"lib/skillSummary": typeof lib_skillSummary;
|
||||
"lib/skillTrustCard": typeof lib_skillTrustCard;
|
||||
"lib/skillZip": typeof lib_skillZip;
|
||||
"lib/skills": typeof lib_skills;
|
||||
"lib/soulChangelog": typeof lib_soulChangelog;
|
||||
|
||||
+25
-16
@@ -219,25 +219,34 @@ export const importGitHubSkill = action({
|
||||
if (!displayName) throw new ConvexError("Display name required");
|
||||
if (!version || !semver.valid(version)) throw new ConvexError("Version must be valid semver");
|
||||
|
||||
const source = {
|
||||
kind: "github" as const,
|
||||
url: resolved.originalUrl,
|
||||
repo: `${resolved.owner}/${resolved.repo}`,
|
||||
ref: resolved.ref,
|
||||
commit: resolved.commit,
|
||||
path: candidate.path,
|
||||
importedAt: Date.now(),
|
||||
};
|
||||
|
||||
let result: Awaited<ReturnType<typeof publishVersionForUser>>;
|
||||
try {
|
||||
result = await publishVersionForUser(ctx, userId, {
|
||||
slug: slugBase,
|
||||
displayName,
|
||||
version,
|
||||
changelog: "",
|
||||
tags,
|
||||
files: storedFiles,
|
||||
source: {
|
||||
kind: "github",
|
||||
url: resolved.originalUrl,
|
||||
repo: `${resolved.owner}/${resolved.repo}`,
|
||||
ref: resolved.ref,
|
||||
commit: resolved.commit,
|
||||
path: candidate.path,
|
||||
importedAt: Date.now(),
|
||||
result = await publishVersionForUser(
|
||||
ctx,
|
||||
userId,
|
||||
{
|
||||
slug: slugBase,
|
||||
displayName,
|
||||
version,
|
||||
changelog: "",
|
||||
tags,
|
||||
files: storedFiles,
|
||||
source,
|
||||
},
|
||||
});
|
||||
{
|
||||
trustCardSource: source,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ConvexError(buildPublishFailureMessage(error));
|
||||
}
|
||||
|
||||
@@ -2095,6 +2095,75 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(json.moderation.isSuspicious).toBe(true);
|
||||
});
|
||||
|
||||
it("returns skill trust card by tag", async () => {
|
||||
const trustCard = {
|
||||
format: "clawhub.skill.trust-card.v1",
|
||||
subject: {
|
||||
kind: "skill",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
version: "1.0.0",
|
||||
},
|
||||
artifact: {
|
||||
fingerprint: "sha256:release",
|
||||
files: [{ path: "SKILL.md", size: 42, sha256: "sha256:file" }],
|
||||
},
|
||||
audit: {
|
||||
status: "pass",
|
||||
summary: "No static findings.",
|
||||
reasonCodes: [],
|
||||
scanners: {
|
||||
static: {
|
||||
status: "clean",
|
||||
summary: "No static findings.",
|
||||
reasonCodes: [],
|
||||
engineVersion: "static-v1",
|
||||
checkedAt: 123,
|
||||
},
|
||||
},
|
||||
},
|
||||
signature: { status: "unsigned" },
|
||||
};
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
tags: { latest: "skillVersions:1", stable: "skillVersions:1" },
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: null,
|
||||
moderationInfo: null,
|
||||
};
|
||||
}
|
||||
if ("versionId" in args) {
|
||||
return {
|
||||
_id: "skillVersions:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 3,
|
||||
files: [],
|
||||
trustCard,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/trust-card?tag=stable"),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.skill).toEqual({ slug: "demo", displayName: "Demo" });
|
||||
expect(json.version).toEqual({ version: "1.0.0", createdAt: 3 });
|
||||
expect(json.trustCard).toEqual(trustCard);
|
||||
});
|
||||
|
||||
it("treats completed llm analysis without verdict as error", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
|
||||
@@ -110,6 +110,7 @@ type PublicSkillVersionResponse = {
|
||||
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
|
||||
staticScan?: PublicSkillVersionStaticScan;
|
||||
capabilityTags?: string[];
|
||||
trustCard?: Doc<"skillVersions">["trustCard"];
|
||||
};
|
||||
|
||||
type ModerationEvidence = {
|
||||
@@ -720,6 +721,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
createdAt: result.latestVersion.createdAt,
|
||||
changelog: result.latestVersion.changelog,
|
||||
license: result.latestVersion.parsed?.license ?? null,
|
||||
trustCard: result.latestVersion.trustCard ?? null,
|
||||
}
|
||||
: null,
|
||||
metadata: result.latestVersion?.parsed?.clawdis
|
||||
@@ -836,6 +838,51 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
);
|
||||
}
|
||||
|
||||
if (second === "trust-card" && segments.length === 2) {
|
||||
const url = new URL(request.url);
|
||||
const versionParam = url.searchParams.get("version")?.trim();
|
||||
const tagParam = url.searchParams.get("tag")?.trim();
|
||||
if (versionParam && tagParam) return text("Use either version or tag", 400, rate.headers);
|
||||
|
||||
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult;
|
||||
if (!result?.skill) {
|
||||
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug);
|
||||
if (hidden) return text(hidden.message, hidden.status, rate.headers);
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
let version = result.latestVersion;
|
||||
if (versionParam) {
|
||||
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
|
||||
skillId: result.skill._id,
|
||||
version: versionParam,
|
||||
});
|
||||
} else if (tagParam) {
|
||||
const versionId = result.skill.tags[tagParam];
|
||||
version = versionId ? await ctx.runQuery(api.skills.getVersionById, { versionId }) : null;
|
||||
}
|
||||
|
||||
if (!version) return text("Version not found", 404, rate.headers);
|
||||
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
|
||||
if (!version.trustCard) return text("Trust card not found", 404, rate.headers);
|
||||
|
||||
return json(
|
||||
{
|
||||
skill: {
|
||||
slug: result.skill.slug,
|
||||
displayName: result.skill.displayName,
|
||||
},
|
||||
version: {
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
},
|
||||
trustCard: version.trustCard,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (second === "versions" && segments.length === 2) {
|
||||
const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult;
|
||||
if (!skillResult?.skill) return text("Skill not found", 404, rate.headers);
|
||||
@@ -889,6 +936,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
contentType: normalizeTextContentType(file.path, file.contentType) ?? null,
|
||||
})),
|
||||
security: security ?? undefined,
|
||||
trustCard: version.trustCard ?? null,
|
||||
},
|
||||
},
|
||||
200,
|
||||
@@ -960,6 +1008,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
}
|
||||
: null,
|
||||
security,
|
||||
trustCard: version.trustCard ?? null,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
|
||||
@@ -79,6 +79,8 @@ export type PublishVersionArgs = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type VerifiedSkillTrustCardSource = Omit<NonNullable<PublishVersionArgs["source"]>, "importedAt">;
|
||||
|
||||
export type PublishOptions = {
|
||||
bypassGitHubAccountAge?: boolean;
|
||||
bypassNewSkillRateLimit?: boolean;
|
||||
@@ -86,6 +88,7 @@ export type PublishOptions = {
|
||||
skipBackup?: boolean;
|
||||
skipWebhook?: boolean;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
trustCardSource?: VerifiedSkillTrustCardSource;
|
||||
// Explicit opt-in to owner migration. The `insertVersion` mutation refuses
|
||||
// to rewrite a skill's `ownerPublisherId` unless this is `true`, so default
|
||||
// publishes (including older CLIs that never pass this flag) can never
|
||||
@@ -319,6 +322,16 @@ export async function publishVersionForUser(
|
||||
changelogSource,
|
||||
tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
|
||||
fingerprint,
|
||||
source: options.trustCardSource
|
||||
? {
|
||||
kind: options.trustCardSource.kind,
|
||||
url: options.trustCardSource.url,
|
||||
repo: options.trustCardSource.repo,
|
||||
ref: options.trustCardSource.ref,
|
||||
commit: options.trustCardSource.commit,
|
||||
path: options.trustCardSource.path,
|
||||
}
|
||||
: undefined,
|
||||
forkOf: args.forkOf
|
||||
? {
|
||||
slug: args.forkOf.slug.trim().toLowerCase(),
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import { buildSkillTrustCard, refreshSkillTrustCardAudit } from "./skillTrustCard";
|
||||
|
||||
describe("buildSkillTrustCard", () => {
|
||||
it("records release identity, hashes, capabilities, audit, and unsigned signature", () => {
|
||||
const card = buildSkillTrustCard({
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
version: "1.2.3",
|
||||
fingerprint: "sha256:release",
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 42,
|
||||
storageId: "storage:skill" as Id<"_storage">,
|
||||
sha256: "sha256:file",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
parsed: {
|
||||
frontmatter: {},
|
||||
license: "MIT-0",
|
||||
clawdis: {
|
||||
os: ["darwin"],
|
||||
requires: {
|
||||
env: ["DEMO_TOKEN"],
|
||||
bins: ["gh"],
|
||||
},
|
||||
envVars: [{ name: "DEMO_TOKEN", required: true }],
|
||||
},
|
||||
} as Doc<"skillVersions">["parsed"],
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/acme/demo/tree/main/skills/demo",
|
||||
repo: "acme/demo",
|
||||
ref: "main",
|
||||
commit: "0123456789abcdef",
|
||||
path: "skills/demo",
|
||||
},
|
||||
capabilityTags: ["github", "shell"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "static-v1",
|
||||
checkedAt: 123,
|
||||
},
|
||||
publisher: {
|
||||
userId: "users:publisher" as Id<"users">,
|
||||
publisherId: "publishers:pub" as Id<"publishers">,
|
||||
handle: "acme",
|
||||
displayName: "Acme",
|
||||
},
|
||||
generatedAt: 456,
|
||||
});
|
||||
|
||||
expect(card.format).toBe("clawhub.skill.trust-card.v1");
|
||||
expect(card.subject).toEqual({
|
||||
kind: "skill",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
version: "1.2.3",
|
||||
});
|
||||
expect(card.source).toEqual({
|
||||
kind: "github",
|
||||
url: "https://github.com/acme/demo/tree/main/skills/demo",
|
||||
repo: "acme/demo",
|
||||
ref: "main",
|
||||
commit: "0123456789abcdef",
|
||||
path: "skills/demo",
|
||||
});
|
||||
expect(card.artifact.files).toEqual([
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 42,
|
||||
sha256: "sha256:file",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
]);
|
||||
expect(card.capabilities.tags).toEqual(["github", "shell"]);
|
||||
expect(card.capabilities.requires?.env).toEqual(["DEMO_TOKEN"]);
|
||||
expect(card.audit.status).toBe("pass");
|
||||
expect(card.audit.scanners.static.status).toBe("clean");
|
||||
expect(card.signature.status).toBe("unsigned");
|
||||
});
|
||||
|
||||
it("does not derive source provenance from author-controlled metadata", () => {
|
||||
const card = buildSkillTrustCard({
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
version: "1.0.0",
|
||||
fingerprint: "sha256:release",
|
||||
files: [],
|
||||
parsed: {
|
||||
frontmatter: {},
|
||||
metadata: {
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/forged/repo",
|
||||
repo: "forged/repo",
|
||||
ref: "main",
|
||||
commit: "0123456789abcdef",
|
||||
path: ".",
|
||||
},
|
||||
},
|
||||
} as Doc<"skillVersions">["parsed"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "static-v1",
|
||||
checkedAt: 123,
|
||||
},
|
||||
publisher: { userId: "users:publisher" as Id<"users"> },
|
||||
generatedAt: 456,
|
||||
});
|
||||
|
||||
expect(card.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps suspicious static scan to review", () => {
|
||||
const card = buildSkillTrustCard({
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
version: "1.0.0",
|
||||
fingerprint: "sha256:release",
|
||||
files: [],
|
||||
parsed: { frontmatter: {} } as Doc<"skillVersions">["parsed"],
|
||||
staticScan: {
|
||||
status: "suspicious",
|
||||
reasonCodes: ["suspicious.network"],
|
||||
findings: [],
|
||||
summary: "Network behavior needs review.",
|
||||
engineVersion: "static-v1",
|
||||
checkedAt: 123,
|
||||
},
|
||||
publisher: { userId: "users:publisher" as Id<"users"> },
|
||||
generatedAt: 456,
|
||||
});
|
||||
|
||||
expect(card.audit.status).toBe("review");
|
||||
expect(card.audit.reasonCodes).toEqual(["suspicious.network"]);
|
||||
});
|
||||
|
||||
it("refreshes audit fields from a later static scan", () => {
|
||||
const card = buildSkillTrustCard({
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
version: "1.0.0",
|
||||
fingerprint: "sha256:release",
|
||||
files: [],
|
||||
parsed: { frontmatter: {} } as Doc<"skillVersions">["parsed"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "static-v1",
|
||||
checkedAt: 123,
|
||||
},
|
||||
publisher: { userId: "users:publisher" as Id<"users"> },
|
||||
generatedAt: 456,
|
||||
});
|
||||
|
||||
const refreshed = refreshSkillTrustCardAudit({
|
||||
trustCard: card,
|
||||
staticScan: {
|
||||
status: "malicious",
|
||||
reasonCodes: ["malicious.install_terminal_payload"],
|
||||
findings: [],
|
||||
summary: "Terminal payload detected.",
|
||||
engineVersion: "static-v2",
|
||||
checkedAt: 789,
|
||||
},
|
||||
generatedAt: 900,
|
||||
});
|
||||
|
||||
expect(refreshed?.generatedAt).toBe(900);
|
||||
expect(refreshed?.artifact).toEqual(card.artifact);
|
||||
expect(refreshed?.audit.status).toBe("malicious");
|
||||
expect(refreshed?.audit.summary).toBe("Terminal payload detected.");
|
||||
expect(refreshed?.audit.scanners.static.engineVersion).toBe("static-v2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
|
||||
type TrustCardPublisher = {
|
||||
userId: Id<"users">;
|
||||
publisherId?: Id<"publishers">;
|
||||
handle?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
type TrustCardInput = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
fingerprint: string;
|
||||
files: Doc<"skillVersions">["files"];
|
||||
parsed: Doc<"skillVersions">["parsed"];
|
||||
source?: SkillTrustCard["source"];
|
||||
capabilityTags?: string[];
|
||||
staticScan: NonNullable<Doc<"skillVersions">["staticScan"]>;
|
||||
publisher: TrustCardPublisher;
|
||||
generatedAt: number;
|
||||
};
|
||||
|
||||
export type SkillTrustCard = NonNullable<Doc<"skillVersions">["trustCard"]>;
|
||||
type StaticScan = NonNullable<Doc<"skillVersions">["staticScan"]>;
|
||||
|
||||
export function buildSkillTrustCard(input: TrustCardInput): SkillTrustCard {
|
||||
const clawdis = input.parsed.clawdis;
|
||||
|
||||
return {
|
||||
format: "clawhub.skill.trust-card.v1",
|
||||
generatedAt: input.generatedAt,
|
||||
generator: {
|
||||
name: "clawhub",
|
||||
version: "skill-trust-card-v1",
|
||||
},
|
||||
subject: {
|
||||
kind: "skill",
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
version: input.version,
|
||||
},
|
||||
publisher: input.publisher,
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
artifact: {
|
||||
fingerprint: input.fingerprint,
|
||||
...(input.parsed.license ? { license: input.parsed.license } : {}),
|
||||
files: input.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
...(file.contentType ? { contentType: file.contentType } : {}),
|
||||
})),
|
||||
},
|
||||
capabilities: {
|
||||
...(input.capabilityTags?.length ? { tags: input.capabilityTags } : {}),
|
||||
...(clawdis?.os?.length ? { os: clawdis.os } : {}),
|
||||
...(clawdis?.requires ? { requires: clawdis.requires } : {}),
|
||||
...(clawdis?.envVars?.length ? { envVars: clawdis.envVars } : {}),
|
||||
...(clawdis?.install?.length ? { install: clawdis.install } : {}),
|
||||
...(clawdis?.dependencies?.length ? { dependencies: clawdis.dependencies } : {}),
|
||||
},
|
||||
audit: skillTrustCardAuditFromStaticScan(input.staticScan),
|
||||
signature: {
|
||||
status: "unsigned",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshSkillTrustCardAudit(input: {
|
||||
trustCard?: SkillTrustCard;
|
||||
staticScan: StaticScan;
|
||||
generatedAt: number;
|
||||
}): SkillTrustCard | undefined {
|
||||
if (!input.trustCard) return undefined;
|
||||
return {
|
||||
...input.trustCard,
|
||||
generatedAt: input.generatedAt,
|
||||
audit: skillTrustCardAuditFromStaticScan(input.staticScan),
|
||||
};
|
||||
}
|
||||
|
||||
function skillTrustCardAuditFromStaticScan(staticScan: StaticScan): SkillTrustCard["audit"] {
|
||||
return {
|
||||
status: auditStatusFromStaticScan(staticScan.status),
|
||||
summary: staticScan.summary,
|
||||
reasonCodes: staticScan.reasonCodes,
|
||||
scanners: {
|
||||
static: {
|
||||
status: staticScan.status,
|
||||
summary: staticScan.summary,
|
||||
reasonCodes: staticScan.reasonCodes,
|
||||
engineVersion: staticScan.engineVersion,
|
||||
checkedAt: staticScan.checkedAt,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function auditStatusFromStaticScan(status: StaticScan["status"]) {
|
||||
switch (status) {
|
||||
case "clean":
|
||||
return "pass" as const;
|
||||
case "suspicious":
|
||||
return "review" as const;
|
||||
case "malicious":
|
||||
return "malicious" as const;
|
||||
}
|
||||
throw new Error("Unknown static scan status");
|
||||
}
|
||||
@@ -374,6 +374,98 @@ const packageFilesValidator = v.array(
|
||||
}),
|
||||
);
|
||||
|
||||
const skillTrustCardValidator = v.object({
|
||||
format: v.literal("clawhub.skill.trust-card.v1"),
|
||||
generatedAt: v.number(),
|
||||
generator: v.object({
|
||||
name: v.literal("clawhub"),
|
||||
version: v.string(),
|
||||
}),
|
||||
subject: v.object({
|
||||
kind: v.literal("skill"),
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
version: v.string(),
|
||||
}),
|
||||
publisher: v.object({
|
||||
userId: v.id("users"),
|
||||
publisherId: v.optional(v.id("publishers")),
|
||||
handle: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
}),
|
||||
source: v.optional(
|
||||
v.object({
|
||||
kind: v.literal("github"),
|
||||
url: v.string(),
|
||||
repo: v.string(),
|
||||
ref: v.string(),
|
||||
commit: v.string(),
|
||||
path: v.string(),
|
||||
}),
|
||||
),
|
||||
artifact: v.object({
|
||||
fingerprint: v.string(),
|
||||
license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
size: v.number(),
|
||||
sha256: v.string(),
|
||||
contentType: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
capabilities: v.object({
|
||||
tags: v.optional(v.array(v.string())),
|
||||
os: v.optional(v.array(v.string())),
|
||||
requires: v.optional(
|
||||
v.object({
|
||||
env: v.optional(v.array(v.string())),
|
||||
bins: v.optional(v.array(v.string())),
|
||||
anyBins: v.optional(v.array(v.string())),
|
||||
config: v.optional(v.array(v.string())),
|
||||
}),
|
||||
),
|
||||
envVars: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
required: v.optional(v.boolean()),
|
||||
description: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
install: v.optional(v.array(v.any())),
|
||||
dependencies: v.optional(v.array(v.any())),
|
||||
}),
|
||||
audit: v.object({
|
||||
status: v.union(
|
||||
v.literal("pass"),
|
||||
v.literal("review"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("error"),
|
||||
),
|
||||
summary: v.string(),
|
||||
reasonCodes: v.array(v.string()),
|
||||
scanners: v.object({
|
||||
static: v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
summary: v.string(),
|
||||
reasonCodes: v.array(v.string()),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
signature: v.object({
|
||||
status: v.union(v.literal("unsigned"), v.literal("verified"), v.literal("invalid")),
|
||||
format: v.optional(v.string()),
|
||||
bundlePath: v.optional(v.string()),
|
||||
checkedAt: v.optional(v.number()),
|
||||
}),
|
||||
});
|
||||
|
||||
const skills = defineTable({
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
@@ -612,6 +704,7 @@ const skillVersions = defineTable({
|
||||
}),
|
||||
),
|
||||
capabilityTags: v.optional(v.array(v.string())),
|
||||
trustCard: v.optional(skillTrustCardValidator),
|
||||
depRegistryAnalysis: v.optional(depRegistryAnalysisValidator),
|
||||
depRegistryScanStatus: v.optional(depRegistryStatusValidator),
|
||||
staticScan: v.optional(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
escalateSkillByIdInternal,
|
||||
escalateByVtInternal,
|
||||
insertVersion,
|
||||
updateVersionDepRegistryAnalysisInternal,
|
||||
updateSkillVersionStaticScanInternal,
|
||||
} from "./skills";
|
||||
|
||||
@@ -25,6 +26,9 @@ const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<
|
||||
const updateSkillVersionStaticScanHandler = (
|
||||
updateSkillVersionStaticScanInternal as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
const updateVersionDepRegistryAnalysisHandler = (
|
||||
updateVersionDepRegistryAnalysisInternal as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
const approveSkillByHashHandler = (
|
||||
approveSkillByHashInternal as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
@@ -89,6 +93,14 @@ function createPublishArgs(overrides?: Partial<Record<string, unknown>>) {
|
||||
metadata: {},
|
||||
clawdis: {},
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "test-static",
|
||||
checkedAt: 1,
|
||||
},
|
||||
embedding: [0.1, 0.2],
|
||||
...overrides,
|
||||
};
|
||||
@@ -1383,12 +1395,37 @@ describe("skills anti-spam guards", () => {
|
||||
});
|
||||
|
||||
it("schedules owner autoban when a latest version static scan becomes malicious", async () => {
|
||||
const trustCard = {
|
||||
format: "clawhub.skill.trust-card.v1",
|
||||
generatedAt: 1,
|
||||
generator: { name: "clawhub", version: "skill-trust-card-v1" },
|
||||
subject: { kind: "skill", slug: "spam-skill", displayName: "Spam Skill", version: "1.0.0" },
|
||||
publisher: { userId: "users:owner" },
|
||||
artifact: { fingerprint: "f".repeat(64), files: [] },
|
||||
capabilities: {},
|
||||
audit: {
|
||||
status: "pass",
|
||||
summary: "No static findings.",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
scanners: {
|
||||
static: {
|
||||
status: "clean",
|
||||
summary: "No static findings.",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
engineVersion: "v2.1.0",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
signature: { status: "unsigned" },
|
||||
};
|
||||
const version = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
staticScan: undefined,
|
||||
sha256hash: "h".repeat(64),
|
||||
trustCard,
|
||||
};
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
@@ -1455,6 +1492,23 @@ describe("skills anti-spam guards", () => {
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:1",
|
||||
expect.objectContaining({
|
||||
trustCard: expect.objectContaining({
|
||||
audit: expect.objectContaining({
|
||||
status: "malicious",
|
||||
reasonCodes: ["malicious.install_terminal_payload"],
|
||||
scanners: expect.objectContaining({
|
||||
static: expect.objectContaining({
|
||||
status: "malicious",
|
||||
engineVersion: "v2.2.0",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
@@ -1475,6 +1529,110 @@ describe("skills anti-spam guards", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes the trust card when dependency registry analysis changes static scan status", async () => {
|
||||
const version = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "v2.2.0",
|
||||
checkedAt: 1,
|
||||
},
|
||||
trustCard: {
|
||||
format: "clawhub.skill.trust-card.v1",
|
||||
generatedAt: 1,
|
||||
generator: { name: "clawhub", version: "skill-trust-card-v1" },
|
||||
subject: {
|
||||
kind: "skill",
|
||||
slug: "dep-risk",
|
||||
displayName: "Dep Risk",
|
||||
version: "1.0.0",
|
||||
},
|
||||
publisher: { userId: "users:owner" },
|
||||
artifact: { fingerprint: "f".repeat(64), files: [] },
|
||||
capabilities: {},
|
||||
audit: {
|
||||
status: "pass",
|
||||
summary: "No static findings.",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
scanners: {
|
||||
static: {
|
||||
status: "clean",
|
||||
summary: "No static findings.",
|
||||
reasonCodes: ["scanner.static.clean"],
|
||||
engineVersion: "v2.2.0",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
signature: { status: "unsigned" },
|
||||
},
|
||||
};
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "dep-risk",
|
||||
latestVersionId: "skillVersions:1",
|
||||
ownerUserId: undefined,
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
manualOverride: undefined,
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const patch = vi.fn();
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "skillVersions:1") return version;
|
||||
if (id === "skills:1") return skill;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table);
|
||||
if (globalStatsQuery) return globalStatsQuery;
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
await updateVersionDepRegistryAnalysisHandler(
|
||||
{ db } as never,
|
||||
{
|
||||
versionId: "skillVersions:1",
|
||||
depRegistryAnalysis: {
|
||||
status: "suspicious",
|
||||
results: [],
|
||||
notFoundPackages: ["phantom-dep"],
|
||||
unresolvedPackages: [],
|
||||
summary: "Missing dependency.",
|
||||
checkedAt: 2,
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:1",
|
||||
expect.objectContaining({
|
||||
depRegistryScanStatus: "suspicious",
|
||||
trustCard: expect.objectContaining({
|
||||
audit: expect.objectContaining({
|
||||
status: "review",
|
||||
reasonCodes: ["suspicious.dep_not_found_on_registry"],
|
||||
scanners: expect.objectContaining({
|
||||
static: expect.objectContaining({
|
||||
status: "suspicious",
|
||||
checkedAt: 2,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps new publishes hidden while the uploader is under moderation", async () => {
|
||||
const storedSkills = new Map<string, Record<string, unknown>>();
|
||||
const storedDigests = new Map<string, Record<string, unknown>>();
|
||||
|
||||
+56
-2
@@ -116,6 +116,7 @@ import {
|
||||
} from "./lib/skillSearchDigest";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
|
||||
import { readCanonicalStat } from "./lib/skillStats";
|
||||
import { buildSkillTrustCard, refreshSkillTrustCardAudit } from "./lib/skillTrustCard";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
import schema from "./schema";
|
||||
@@ -1647,6 +1648,7 @@ type PublicSkillVersion = {
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
|
||||
trustCard?: Doc<"skillVersions">["trustCard"];
|
||||
staticScan?: {
|
||||
status: NonNullable<Doc<"skillVersions">["staticScan"]>["status"];
|
||||
reasonCodes: NonNullable<Doc<"skillVersions">["staticScan"]>["reasonCodes"];
|
||||
@@ -1863,6 +1865,7 @@ function toPublicSkillVersion(
|
||||
sha256hash: version.sha256hash,
|
||||
vtAnalysis: version.vtAnalysis,
|
||||
llmAnalysis: version.llmAnalysis,
|
||||
trustCard: version.trustCard,
|
||||
clawScanNote: version.clawScanNote,
|
||||
staticScan: version.staticScan
|
||||
? {
|
||||
@@ -6228,10 +6231,21 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
if (!version || version.skillId !== args.skillId)
|
||||
return { ok: true as const, skipped: "missing" as const };
|
||||
|
||||
const now = Date.now();
|
||||
const trustCard = refreshSkillTrustCardAudit({
|
||||
trustCard: version.trustCard,
|
||||
staticScan: args.staticScan,
|
||||
generatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(version._id, {
|
||||
staticScan: args.staticScan,
|
||||
...(trustCard ? { trustCard } : {}),
|
||||
});
|
||||
const updatedVersion = { ...version, staticScan: args.staticScan };
|
||||
const updatedVersion = {
|
||||
...version,
|
||||
staticScan: args.staticScan,
|
||||
...(trustCard ? { trustCard } : {}),
|
||||
};
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return { ok: true as const, skipped: "missing" as const };
|
||||
@@ -6240,7 +6254,6 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
}
|
||||
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
const now = Date.now();
|
||||
const basePatch = buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: updatedVersion,
|
||||
@@ -6281,16 +6294,23 @@ export const updateVersionDepRegistryAnalysisInternal = internalMutation({
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version) return { ok: true as const, skipped: "missing" as const };
|
||||
|
||||
const now = Date.now();
|
||||
const staticScan = mergeDepRegistryFinding({
|
||||
staticScan: version.staticScan,
|
||||
analysis: args.depRegistryAnalysis,
|
||||
statusFromCodes: verdictFromCodes,
|
||||
summarizeCodes: summarizeReasonCodes,
|
||||
});
|
||||
const trustCard = refreshSkillTrustCardAudit({
|
||||
trustCard: version.trustCard,
|
||||
staticScan,
|
||||
generatedAt: now,
|
||||
});
|
||||
const versionPatch = {
|
||||
depRegistryAnalysis: args.depRegistryAnalysis,
|
||||
depRegistryScanStatus: args.depRegistryAnalysis.status,
|
||||
staticScan,
|
||||
...(trustCard ? { trustCard } : {}),
|
||||
};
|
||||
|
||||
await ctx.db.patch(version._id, versionPatch);
|
||||
@@ -9315,6 +9335,16 @@ export const insertVersion = internalMutation({
|
||||
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
fingerprint: v.string(),
|
||||
source: v.optional(
|
||||
v.object({
|
||||
kind: v.literal("github"),
|
||||
url: v.string(),
|
||||
repo: v.string(),
|
||||
ref: v.string(),
|
||||
commit: v.string(),
|
||||
path: v.string(),
|
||||
}),
|
||||
),
|
||||
bypassNewSkillRateLimit: v.optional(v.boolean()),
|
||||
forkOf: v.optional(
|
||||
v.object({
|
||||
@@ -9825,6 +9855,29 @@ export const insertVersion = internalMutation({
|
||||
}
|
||||
|
||||
const clawScanNote = normalizeClawScanNoteForWrite(args.clawScanNote);
|
||||
const trustPublisherId = skill.ownerPublisherId ?? ownerPublisherId;
|
||||
const trustPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: trustPublisherId,
|
||||
ownerUserId: userId,
|
||||
});
|
||||
const trustCard = buildSkillTrustCard({
|
||||
slug: skill.slug,
|
||||
displayName: args.displayName,
|
||||
version: args.version,
|
||||
fingerprint: args.fingerprint,
|
||||
files: args.files,
|
||||
parsed: args.parsed,
|
||||
source: args.source,
|
||||
capabilityTags: args.capabilityTags,
|
||||
staticScan: args.staticScan,
|
||||
publisher: {
|
||||
userId,
|
||||
...(trustPublisherId ? { publisherId: trustPublisherId } : {}),
|
||||
...(trustPublisher?.handle ? { handle: trustPublisher.handle } : {}),
|
||||
...(trustPublisher?.displayName ? { displayName: trustPublisher.displayName } : {}),
|
||||
},
|
||||
generatedAt: now,
|
||||
});
|
||||
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId: skill._id,
|
||||
@@ -9836,6 +9889,7 @@ export const insertVersion = internalMutation({
|
||||
files: args.files,
|
||||
parsed: args.parsed,
|
||||
capabilityTags: args.capabilityTags,
|
||||
trustCard,
|
||||
staticScan: args.staticScan,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
|
||||
@@ -172,6 +172,26 @@ ClawScan does not treat a scary-looking capability as automatically malicious.
|
||||
It asks whether the capability is disclosed, purpose-aligned, and supported by
|
||||
the release's stated use case.
|
||||
|
||||
## Skill trust cards
|
||||
|
||||
Every newly published skill version gets a machine-readable trust card. The card
|
||||
records the exact version, publisher, verified source metadata when available,
|
||||
file hashes, declared capabilities, static audit result, and signature status.
|
||||
|
||||
Trust cards are meant for installers, automation, and reviewers that need a stable
|
||||
release record instead of scraping UI text. Fetch one with:
|
||||
|
||||
```sh
|
||||
clawhub skill verify <slug>
|
||||
```
|
||||
|
||||
The same data is available at `/api/v1/skills/<slug>/trust-card`. Add
|
||||
`?version=<version>` or `?tag=<tag>` to verify a specific release.
|
||||
|
||||
Current cards report `signature: unsigned` until ClawHub ships release signing.
|
||||
Unsigned still means the artifact was hashed and audited by ClawHub; it does not
|
||||
prove third-party attestation or offline integrity.
|
||||
|
||||
## Publisher notes
|
||||
|
||||
Publishers can add a note when publishing a skill or plugin. On the Security
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
cmdUndeleteSkill,
|
||||
cmdUnhideSkill,
|
||||
} from "./cli/commands/delete.js";
|
||||
import { cmdInspect } from "./cli/commands/inspect.js";
|
||||
import { cmdInspect, cmdVerifySkill } from "./cli/commands/inspect.js";
|
||||
import { cmdMergeSkill, cmdRenameSkill } from "./cli/commands/ownership.js";
|
||||
import {
|
||||
cmdDeletePackage,
|
||||
@@ -382,6 +382,17 @@ registerCommand(skill, ["skill", "publish"])
|
||||
await cmdPublish(opts, folder, options);
|
||||
});
|
||||
|
||||
registerCommand(skill, ["skill", "verify"])
|
||||
.description("Verify a published skill trust card")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--version <version>", "Version to verify")
|
||||
.option("--tag <tag>", "Tag to verify")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdVerifySkill(opts, slug, options);
|
||||
});
|
||||
|
||||
const publisherCmd = registerCommandGroup(program, ["publisher"])
|
||||
.description("Publisher organization commands")
|
||||
.showHelpAfterError()
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdInspect } = await import("./inspect");
|
||||
const { cmdInspect, cmdVerifySkill } = await import("./inspect");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
@@ -288,3 +288,55 @@ describe("cmdInspect", () => {
|
||||
).rejects.toThrow("Use either --version or --tag");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdVerifySkill", () => {
|
||||
it("fetches and prints a skill trust card", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
skill: { slug: "demo", displayName: "Demo" },
|
||||
version: { version: "1.2.3", createdAt: 3 },
|
||||
trustCard: {
|
||||
subject: { kind: "skill", slug: "demo", displayName: "Demo", version: "1.2.3" },
|
||||
publisher: { handle: "acme" },
|
||||
source: {
|
||||
kind: "github",
|
||||
repo: "acme/demo",
|
||||
commit: "0123456789abcdef",
|
||||
path: "skills/demo",
|
||||
},
|
||||
artifact: {
|
||||
fingerprint: "sha256:release",
|
||||
files: [{ path: "SKILL.md", size: 42, sha256: "sha256:file" }],
|
||||
},
|
||||
capabilities: {
|
||||
tags: ["github", "shell"],
|
||||
requires: { env: ["DEMO_TOKEN"], bins: ["gh"] },
|
||||
},
|
||||
audit: {
|
||||
status: "pass",
|
||||
summary: "No static findings.",
|
||||
reasonCodes: [],
|
||||
},
|
||||
signature: { status: "unsigned" },
|
||||
},
|
||||
});
|
||||
|
||||
await cmdVerifySkill(makeGlobalOpts(), "demo", { tag: "latest" });
|
||||
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1];
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/skills/demo/trust-card");
|
||||
expect(url.searchParams.get("tag")).toBe("latest");
|
||||
expect(mockLog).toHaveBeenCalledWith("demo@1.2.3 trust");
|
||||
expect(mockLog).toHaveBeenCalledWith("Audit: PASS");
|
||||
expect(mockLog).toHaveBeenCalledWith("Signature: unsigned");
|
||||
expect(mockLog).toHaveBeenCalledWith("Source: acme/demo@0123456789ab skills/demo");
|
||||
expect(mockLog).toHaveBeenCalledWith("Capabilities: github, shell");
|
||||
expect(mockLog).toHaveBeenCalledWith("Requires: env=DEMO_TOKEN; bins=gh");
|
||||
});
|
||||
|
||||
it("rejects when both version and tag are provided", async () => {
|
||||
await expect(
|
||||
cmdVerifySkill(makeGlobalOpts(), "demo", { version: "1.0.0", tag: "latest" }),
|
||||
).rejects.toThrow("Use either --version or --tag");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
ApiV1SkillModerationResponseSchema,
|
||||
ApiV1SkillResponseSchema,
|
||||
ApiV1SkillTrustCardResponseSchema,
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
} from "../../schema/index.js";
|
||||
@@ -23,6 +24,12 @@ type InspectOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type VerifySkillOptions = {
|
||||
version?: string;
|
||||
tag?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type FileEntry = {
|
||||
path: string;
|
||||
size: number | null;
|
||||
@@ -224,6 +231,48 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdVerifySkill(
|
||||
opts: GlobalOpts,
|
||||
slug: string,
|
||||
options: VerifySkillOptions = {},
|
||||
) {
|
||||
const trimmed = slug.trim();
|
||||
if (!trimmed) fail("Slug required");
|
||||
if (options.version && options.tag) fail("Use either --version or --tag");
|
||||
|
||||
const token = await getOptionalAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = createSpinner("Fetching trust card");
|
||||
try {
|
||||
const url = registryUrl(
|
||||
`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/trust-card`,
|
||||
registry,
|
||||
);
|
||||
if (options.version) {
|
||||
url.searchParams.set("version", options.version);
|
||||
} else if (options.tag) {
|
||||
url.searchParams.set("tag", options.tag);
|
||||
}
|
||||
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", url: url.toString(), token },
|
||||
ApiV1SkillTrustCardResponseSchema,
|
||||
);
|
||||
spinner.stop();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printTrustCardSummary(trimmed, result.trustCard);
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchSkillDetail(registry: string, slug: string, token: string | undefined) {
|
||||
return apiRequest(
|
||||
registry,
|
||||
@@ -438,6 +487,114 @@ function printSecuritySummary(version: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function printTrustCardSummary(slug: string, trustCard: unknown) {
|
||||
const card = normalizeTrustCard(trustCard);
|
||||
if (!card) {
|
||||
console.log(`${slug} trust`);
|
||||
console.log("Trust card: unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
const version = card.subject?.version ?? "?";
|
||||
const displayName = card.subject?.displayName;
|
||||
console.log(`${card.subject?.slug ?? slug}@${version} trust`);
|
||||
if (displayName) console.log(`Name: ${displayName}`);
|
||||
if (card.publisher) {
|
||||
const publisher = card.publisher.handle ?? card.publisher.displayName;
|
||||
if (publisher) console.log(`Publisher: ${publisher}`);
|
||||
}
|
||||
console.log(`Audit: ${card.audit.status.toUpperCase()}`);
|
||||
if (card.audit.summary) console.log(`Audit Summary: ${truncate(card.audit.summary, 160)}`);
|
||||
if (card.audit.reasonCodes.length)
|
||||
console.log(`Audit Reasons: ${card.audit.reasonCodes.join(", ")}`);
|
||||
console.log(`Signature: ${card.signature.status}`);
|
||||
console.log(`Fingerprint: ${card.artifact.fingerprint}`);
|
||||
console.log(`Files: ${card.artifact.files.length}`);
|
||||
const source = formatTrustCardSource(card.source);
|
||||
if (source) console.log(`Source: ${source}`);
|
||||
if (card.capabilities.tags.length)
|
||||
console.log(`Capabilities: ${card.capabilities.tags.join(", ")}`);
|
||||
const requires = formatTrustCardRequires(card.capabilities.requires);
|
||||
if (requires) console.log(`Requires: ${requires}`);
|
||||
}
|
||||
|
||||
function normalizeTrustCard(value: unknown) {
|
||||
const record = asRecord(value);
|
||||
if (!record) return null;
|
||||
const subject = asRecord(record.subject);
|
||||
const publisher = asRecord(record.publisher);
|
||||
const artifact = asRecord(record.artifact);
|
||||
const audit = asRecord(record.audit);
|
||||
const signature = asRecord(record.signature);
|
||||
const capabilities = asRecord(record.capabilities);
|
||||
if (!artifact || !audit || !signature || !capabilities) return null;
|
||||
|
||||
const fingerprint = getString(artifact.fingerprint);
|
||||
if (!fingerprint) return null;
|
||||
const auditStatus = getString(audit.status);
|
||||
const signatureStatus = getString(signature.status);
|
||||
if (!auditStatus || !signatureStatus) return null;
|
||||
|
||||
return {
|
||||
subject: subject
|
||||
? {
|
||||
slug: getString(subject.slug),
|
||||
displayName: getString(subject.displayName),
|
||||
version: getString(subject.version),
|
||||
}
|
||||
: null,
|
||||
publisher: publisher
|
||||
? {
|
||||
handle: getString(publisher.handle),
|
||||
displayName: getString(publisher.displayName),
|
||||
}
|
||||
: null,
|
||||
source: asRecord(record.source),
|
||||
artifact: {
|
||||
fingerprint,
|
||||
files: Array.isArray(artifact.files) ? artifact.files : [],
|
||||
},
|
||||
audit: {
|
||||
status: auditStatus,
|
||||
summary: getString(audit.summary),
|
||||
reasonCodes: getStringArray(audit.reasonCodes),
|
||||
},
|
||||
signature: {
|
||||
status: signatureStatus,
|
||||
},
|
||||
capabilities: {
|
||||
tags: getStringArray(capabilities.tags),
|
||||
requires: asRecord(capabilities.requires),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatTrustCardSource(source: Record<string, unknown> | null | undefined) {
|
||||
if (!source) return null;
|
||||
const repo = getString(source.repo);
|
||||
const commit = getString(source.commit);
|
||||
const path = getString(source.path);
|
||||
const url = getString(source.url);
|
||||
if (repo && commit && path) return `${repo}@${commit.slice(0, 12)} ${path}`;
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
function formatTrustCardRequires(requires: Record<string, unknown> | null | undefined) {
|
||||
if (!requires) return null;
|
||||
const parts = [
|
||||
formatRequirementList("env", requires.env),
|
||||
formatRequirementList("bins", requires.bins),
|
||||
formatRequirementList("anyBins", requires.anyBins),
|
||||
formatRequirementList("config", requires.config),
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return parts.length ? parts.join("; ") : null;
|
||||
}
|
||||
|
||||
function formatRequirementList(label: string, value: unknown) {
|
||||
const items = getStringArray(value);
|
||||
return items.length ? `${label}=${items.join(",")}` : null;
|
||||
}
|
||||
|
||||
function normalizeSecurity(security: unknown): SecurityStatus | null {
|
||||
if (!security || typeof security !== "object") return null;
|
||||
const value = security as {
|
||||
@@ -500,3 +657,19 @@ function truncate(str: string, maxLen: number) {
|
||||
if (str.length <= maxLen) return str;
|
||||
return `${str.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function getStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim()))
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ export const ApiV1SkillResponseSchema = type({
|
||||
createdAt: "number",
|
||||
changelog: "string",
|
||||
license: '"MIT-0"|null?',
|
||||
trustCard: "unknown?",
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
handle: "string|null",
|
||||
@@ -431,6 +432,7 @@ export const ApiV1SkillVersionResponseSchema = type({
|
||||
changelogSource: '"auto"|"user"|null?',
|
||||
license: '"MIT-0"|null?',
|
||||
files: "unknown?",
|
||||
trustCard: "unknown?",
|
||||
}).or("null"),
|
||||
skill: type({
|
||||
slug: "string",
|
||||
@@ -438,6 +440,18 @@ export const ApiV1SkillVersionResponseSchema = type({
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
export const ApiV1SkillTrustCardResponseSchema = type({
|
||||
skill: type({
|
||||
slug: "string",
|
||||
displayName: "string",
|
||||
}).or("null"),
|
||||
version: type({
|
||||
version: "string",
|
||||
createdAt: "number?",
|
||||
}).or("null"),
|
||||
trustCard: "unknown",
|
||||
});
|
||||
|
||||
export const ApiV1SkillResolveResponseSchema = type({
|
||||
match: type({ version: "string" }).or("null"),
|
||||
latestVersion: type({ version: "string" }).or("null"),
|
||||
|
||||
Reference in New Issue
Block a user