From 15166e81caa1b267656295265f2ffd0cefc2812d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 16 Jun 2026 14:36:42 +0800 Subject: [PATCH] fix(skills): trust verified ClawHub source provenance (#93506) Merged via squash. Prepared head SHA: a9ec22fa47085024c0af7da047c31523533ba5af Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc --- src/skills/lifecycle/clawhub.test.ts | 184 +++++++++++++++++++++++++++ src/skills/lifecycle/clawhub.ts | 32 +++-- 2 files changed, 208 insertions(+), 8 deletions(-) diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index 8c83486f4aa9..f72751e58066 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -382,6 +382,190 @@ describe("skills-clawhub", () => { } }); + it("persists the source URL from server-resolved verification provenance", async () => { + const workspaceDir = await tempDirs.make("openclaw-skills-source-"); + const sourceUrl = "https://github.com/openclaw/skills/tree/def456/agentreceipt"; + fetchClawHubSkillDetailMock.mockResolvedValueOnce({ + skill: { + slug: "agentreceipt", + displayName: "AgentReceipt", + createdAt: 1, + updatedAt: 2, + }, + latestVersion: { + version: "1.0.0", + createdAt: 3, + }, + }); + fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + card: { available: true }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: { + source: "server-resolved-github-import", + kind: "github", + url: sourceUrl, + repo: "openclaw/skills", + ref: "main", + commit: "def456", + path: "agentreceipt", + importedAt: 4, + }, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); + installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { + await fs.mkdir(params.targetDir, { recursive: true }); + await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# AgentReceipt\n", "utf8"); + return { ok: true, targetDir: params.targetDir }; + }); + + try { + const result = await installSkillFromClawHub({ + workspaceDir, + slug: "agentreceipt", + version: "1.0.0", + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + targetDir: path.join(workspaceDir, "skills", "agentreceipt"), + }); + const lock = JSON.parse( + await fs.readFile(path.join(workspaceDir, ".clawhub", "lock.json"), "utf8"), + ) as { skills: Record> }; + expect(lock.skills.agentreceipt).toMatchObject({ + sourceUrl, + verification: { + provenance: { + source: "server-resolved-github-import", + kind: "github", + url: sourceUrl, + repo: "openclaw/skills", + ref: "main", + commit: "def456", + path: "agentreceipt", + importedAt: 4, + }, + }, + }); + const origin = JSON.parse( + await fs.readFile( + path.join(workspaceDir, "skills", "agentreceipt", ".clawhub", "origin.json"), + "utf8", + ), + ) as Record; + expect(origin.sourceUrl).toBe(sourceUrl); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("does not treat detail metadata as verified source provenance", async () => { + const workspaceDir = await tempDirs.make("openclaw-skills-source-"); + fetchClawHubSkillDetailMock.mockResolvedValueOnce({ + skill: { + slug: "agentreceipt", + displayName: "AgentReceipt", + createdAt: 1, + updatedAt: 2, + sourceUrl: "https://github.com/openclaw/skills/tree/latest/agentreceipt", + }, + latestVersion: { + version: "1.0.0", + createdAt: 3, + sourceUrl: "https://github.com/openclaw/skills/tree/latest/agentreceipt", + }, + }); + fetchClawHubSkillVerificationMock.mockRejectedValueOnce(new Error("verification down")); + installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { + await fs.mkdir(params.targetDir, { recursive: true }); + await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# AgentReceipt\n", "utf8"); + return { ok: true, targetDir: params.targetDir }; + }); + + try { + const result = await installSkillFromClawHub({ + workspaceDir, + slug: "agentreceipt", + version: "1.0.0", + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + targetDir: path.join(workspaceDir, "skills", "agentreceipt"), + }); + const lock = JSON.parse( + await fs.readFile(path.join(workspaceDir, ".clawhub", "lock.json"), "utf8"), + ) as { skills: Record> }; + expect(lock.skills.agentreceipt?.sourceUrl).toBeUndefined(); + const origin = JSON.parse( + await fs.readFile( + path.join(workspaceDir, "skills", "agentreceipt", ".clawhub", "origin.json"), + "utf8", + ), + ) as Record; + expect(origin.sourceUrl).toBeUndefined(); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("does not trust URLs from unavailable verification provenance", async () => { + const workspaceDir = await tempDirs.make("openclaw-skills-source-"); + fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + card: { available: true }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: { + source: "unavailable", + url: "https://github.com/openclaw/skills/tree/unverified/agentreceipt", + }, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); + installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { + await fs.mkdir(params.targetDir, { recursive: true }); + await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# AgentReceipt\n", "utf8"); + return { ok: true, targetDir: params.targetDir }; + }); + + try { + const result = await installSkillFromClawHub({ + workspaceDir, + slug: "agentreceipt", + version: "1.0.0", + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + targetDir: path.join(workspaceDir, "skills", "agentreceipt"), + }); + const lock = JSON.parse( + await fs.readFile(path.join(workspaceDir, ".clawhub", "lock.json"), "utf8"), + ) as { skills: Record> }; + expect(lock.skills.agentreceipt?.sourceUrl).toBeUndefined(); + const origin = JSON.parse( + await fs.readFile( + path.join(workspaceDir, "skills", "agentreceipt", ".clawhub", "origin.json"), + "utf8", + ), + ) as Record; + expect(origin.sourceUrl).toBeUndefined(); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("keeps installing when the ClawHub verification snapshot is unavailable", async () => { const workspaceDir = await tempDirs.make("openclaw-skills-lock-"); fetchClawHubSkillVerificationMock.mockRejectedValueOnce(new Error("verification down")); diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index 37f72eedb913..ed9203b3cbfc 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -273,12 +273,29 @@ function normalizeOptionalStringValue(raw: unknown): string | undefined { return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; } -function readSkillDetailSourceUrl(detail: ClawHubSkillDetail | undefined): string | undefined { - const skill = detail?.skill; - if (!skill || typeof skill !== "object") { +function asRecord(raw: unknown): Record | undefined { + return raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : undefined; +} + +function readVerifiedProvenanceSourceUrl(raw: unknown): string | undefined { + const provenance = asRecord(raw); + // Only this ClawHub variant is server-resolved; other provenance metadata + // must not become a trusted source link. + if (provenance?.source !== "server-resolved-github-import") { return undefined; } - return normalizeOptionalStringValue((skill as { sourceUrl?: unknown }).sourceUrl); + return normalizeOptionalStringValue(provenance.url); +} + +function readInstallResolutionSourceUrl( + resolution: Extract | undefined, +): string | undefined { + if (resolution?.installKind !== "github") { + return undefined; + } + return normalizeOptionalStringValue(resolution.github.sourceUrl); } function buildDownloadedArtifactLock( @@ -1106,10 +1123,6 @@ async function performClawHubSkillInstall( const installedAt = Date.now(); const artifact = buildDownloadedArtifactLock(archive); - const sourceUrl = - latestResolution?.installKind === "github" - ? normalizeOptionalStringValue(latestResolution.github.sourceUrl) - : readSkillDetailSourceUrl(detail); const verificationVersion = latestResolution?.installKind === "github" && !params.version ? undefined : version; const [skillFile, verification] = await Promise.all([ @@ -1120,6 +1133,9 @@ async function performClawHubSkillInstall( baseUrl: params.baseUrl, }), ]); + const sourceUrl = + readInstallResolutionSourceUrl(latestResolution) ?? + readVerifiedProvenanceSourceUrl(verification?.provenance); await writeClawHubSkillOrigin(install.targetDir, { version: 1, registry: resolveClawHubBaseUrl(params.baseUrl),