Compare commits

...
Author SHA1 Message Date
Peter Steinberger 347773d8c9 feat: add skill trust cards 2026-05-21 22:18:44 +01:00
15 changed files with 1036 additions and 20 deletions
+2
View File
@@ -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
View File
@@ -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));
}
+69
View File
@@ -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) {
+49
View File
@@ -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,
+13
View File
@@ -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(),
+189
View File
@@ -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");
});
});
+110
View File
@@ -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");
}
+93
View File
@@ -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(
+158
View File
@@ -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
View File
@@ -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,
+20
View File
@@ -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
+12 -1
View File
@@ -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()))
: [];
}
+14
View File
@@ -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"),