diff --git a/CLAUDE.md b/CLAUDE.md index ddcd069..bf0b4e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ Search algorithm: `./docs/search-algorithm.md` - **Max 200 LOC per file** — split into focused modules if exceeded. - **Seed data** in `scripts/seed-data.json` (30 real skills from skills.sh). Seed via `ADMIN_SECRET=... pnpm seed`. - **Scoped API keys** — `api_keys.scopes` NULL = legacy key; scoped keys (`releases:publish:@handle`) work only on the release publish API. They are stored under `hashScopedApiKey` (domain-separated), so legacy routes (`authenticateApiKey`, by plain key hash) never accept them. Apply migration 0012 to remote D1 before deploying (`docs/deployment-guide.md`). -- **Releases are byte-exact** — the release publish API (`app/lib/releases/`) never sanitizes; the scan only picks `published` vs `quarantined`. R2 writes are create-only, the D1 row is written last. See `docs/api-reference.md#publishing-releases`. +- **Releases are byte-exact** — the release publish API (`app/lib/releases/`) never sanitizes; the scan only picks `published` vs `quarantined`. R2 writes are create-only, the D1 row is written last. Only `@agentkit/*` kit releases (metadata `provenance.provider = "kit"`) are signed as `skillx.release/v2`; everything else stays v1, and a registry signature never yields `signed-kit` trust. See `docs/api-reference.md#publishing-releases`. - **Content security scanning** — all SKILL.md content scanned for prompt injection, invisible chars, ANSI escapes, shell injection. `risk_label` column stores result ("safe"/"caution"/"danger"/"unknown"). Sanitization strips zero-width Unicode + ANSI escapes before storage. ## Deployment diff --git a/apps/web/app/lib/releases/ingest-release.ts b/apps/web/app/lib/releases/ingest-release.ts index a3f2b4a..312ceb5 100644 --- a/apps/web/app/lib/releases/ingest-release.ts +++ b/apps/web/app/lib/releases/ingest-release.ts @@ -2,24 +2,26 @@ * Release ingest: `POST /api/v1/packages/@handle/slug/releases`. * * Order (each step rejects before any later one runs): - * name and reserved namespaces -> scoped key and publisher ownership -> + * name and reserved namespaces (for the requested provider) -> scoped key and publisher ownership -> * signing key -> metadata -> capped upload -> tar read with limits and tree * digest -> version conflict -> exec manifest -> scan -> signed manifest -> R2 artifact, blobs * and manifest (create-only) -> D1 row (last). Release bytes are stored - * exactly as uploaded; nothing is sanitized. + * exactly as uploaded; nothing is sanitized. Kit releases (`@agentkit/*`, + * see release-kit.ts) are signed as `skillx.release/v2`, all others as v1. */ -import { ReleaseManifestError, SKILLX_RELEASE_SCHEMA_VERSION } from "@skillx/contracts"; -import { ERROR_CODES, SkillTreeError, readTarTree } from "@skillx/skill-tree"; -import type { TreeManifest } from "@skillx/skill-tree"; +import { ReleaseManifestError } from "@skillx/contracts"; +import type { ReleaseProvenance } from "@skillx/contracts"; import { authorizeReleasePublisher } from "./release-auth"; import { checkExecManifest } from "./release-exec"; import { ReleaseHttpError, errorResponse, releaseError } from "./release-http"; -import { BLOB_WRITE_CONCURRENCY, MAX_RELEASE_UPLOAD_BYTES, RELEASE_SERVER_TREE_LIMITS, assertSubrequestBudget } from "./release-limits"; -import { RELEASE_METADATA_HEADER, parseReleaseMetadata, parseReleaseName, readUploadBody } from "./release-request"; +import { BLOB_WRITE_CONCURRENCY, MAX_RELEASE_UPLOAD_BYTES, assertSubrequestBudget } from "./release-limits"; +import { RELEASE_METADATA_HEADER, parseReleaseMetadata, parseReleaseName, readUploadBody, requestedProvider } from "./release-request"; import { scanRelease } from "./release-scan"; +import { readTree, sha256Hex } from "./release-tree-read"; import { findPackage, findRelease, insertRelease } from "./release-store"; import type { ExistingRelease } from "./release-store"; import { artifactKey, blobKey, forEachLimited, manifestKey, putCreateOnly } from "./r2-create-only"; +import { assertSameKitProvenance } from "./release-kit"; import { signRelease } from "./sign-manifest"; import type { RegistrySigner } from "./sign-manifest"; @@ -32,42 +34,14 @@ export interface IngestDeps { subrequestBudget?: number; } -interface ReadTree { - tree: TreeManifest; - contents: Map; -} - -async function sha256Hex(bytes: Uint8Array): Promise { - const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array); - return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); -} - -/** Streams the uncompressed tar through the shared reader; every unsafe entry fails here, before storage. */ -async function readTree(body: Uint8Array): Promise { - const seen = new Map(); - try { - const tree = await readTarTree(body, { - compression: "none", - limits: RELEASE_SERVER_TREE_LIMITS, - onFileContent: (path, content) => void seen.set(path, content), - }); - const contents = new Map(); - for (const file of tree.files) { - const bytes = seen.get(file.path); - if (bytes === undefined || bytes.length !== file.size) throw new Error(`tree reader lost the content of ${file.path}`); - contents.set(file.path, bytes); - } - return { tree, contents }; - } catch (error) { - if (!(error instanceof SkillTreeError)) throw error; - throw releaseError(error.code === ERROR_CODES.limitExceeded ? 413 : 422, error.code, error.message); - } -} +type Kit = { bucket: R2Bucket; provenance: ReleaseProvenance | null }; -function existingOutcome(identity: string, version: string, existing: ExistingRelease, releaseDigest: string): Response { +/** The replay answer for an existing version: same tree (and, for a kit release, same provenance) or 409. */ +async function existingOutcome(identity: string, version: string, existing: ExistingRelease, releaseDigest: string, kit: Kit): Promise { if (existing.releaseDigest !== releaseDigest) { throw releaseError(409, "release.version_exists", `${identity}@${version} already exists with different content`); } + if (kit.provenance !== null) await assertSameKitProvenance(kit.bucket, existing.manifestKey, kit.provenance, `${identity}@${version}`); return Response.json({ identity, version, @@ -80,10 +54,11 @@ function existingOutcome(identity: string, version: string, existing: ExistingRe } async function ingest(request: Request, params: { scope?: string; slug?: string }, deps: IngestDeps): Promise { - const name = parseReleaseName(params.scope, params.slug); + const header = request.headers.get(RELEASE_METADATA_HEADER); + const name = parseReleaseName(params.scope, params.slug, requestedProvider(header)); const auth = await authorizeReleasePublisher(deps.db, request, name.handle); const signer = await deps.loadSigner(); - const metadata = parseReleaseMetadata(request.headers.get(RELEASE_METADATA_HEADER)); + const metadata = parseReleaseMetadata(header); const pkg = await findPackage(deps.db, name.identity); if (pkg !== null && pkg.publisherId !== auth.publisherId) { throw releaseError(403, "publisher.not_owner", `${name.identity} belongs to another publisher`); @@ -95,7 +70,8 @@ async function ingest(request: Request, params: { scope?: string; slug?: string const body = await readUploadBody(request, MAX_RELEASE_UPLOAD_BYTES); const { tree, contents } = await readTree(body); const existing = await findRelease(deps.db, name.identity, metadata.version); - if (existing !== null) return existingOutcome(name.identity, metadata.version, existing, tree.digest); + const kit = { bucket: deps.bucket, provenance: metadata.provenance }; + if (existing !== null) return existingOutcome(name.identity, metadata.version, existing, tree.digest, kit); const exec = await checkExecManifest(contents, metadata.declaredScripts); const scan = scanRelease(tree.files.map((f) => ({ path: f.path, bytes: contents.get(f.path)! }))); @@ -119,6 +95,7 @@ async function ingest(request: Request, params: { scope?: string; slug?: string publisher: { handle: name.handle, verifiedBy: "admin" }, sourceProvenance: metadata.sourceProvenance, declaredScripts: metadata.declaredScripts, + provenance: metadata.provenance, }, signer, now, @@ -154,7 +131,7 @@ async function ingest(request: Request, params: { scope?: string; slug?: string version: metadata.version, channel: metadata.channel, state: scan.state, - manifestSchemaVersion: SKILLX_RELEASE_SCHEMA_VERSION, + manifestSchemaVersion: signed.manifest.schemaVersion, artifactKey: artifactKey(artifactHex), artifactDigest: `sha256:${artifactHex}`, artifactSize: body.length, @@ -170,7 +147,7 @@ async function ingest(request: Request, params: { scope?: string; slug?: string const winner = await findRelease(deps.db, name.identity, metadata.version); // The release insert matched no package row: one with this name was created concurrently by another publisher or kind. if (winner === null) throw releaseError(409, "release.kind_conflict", `${name.identity} was created concurrently with another kind or publisher`); - return existingOutcome(name.identity, metadata.version, winner, tree.digest); + return existingOutcome(name.identity, metadata.version, winner, tree.digest, kit); } return Response.json({ identity: name.identity, diff --git a/apps/web/app/lib/releases/release-kit.ts b/apps/web/app/lib/releases/release-kit.ts new file mode 100644 index 0000000..59e43f5 --- /dev/null +++ b/apps/web/app/lib/releases/release-kit.ts @@ -0,0 +1,73 @@ +/** + * Kit releases: `@agentkit/*` content published with metadata + * `provenance: {provider: "kit", origin}` and signed as `skillx.release/v2`. + * The name rules, the metadata member and the replay check live here; the + * scoped key and active `agentkit` publisher are checked by release-auth. + */ +import { KIT_ORIGINS, PACKAGE_NAME_PATTERN, RELEASE_PROVIDERS } from "@skillx/contracts"; +import type { KitOrigin, ReleaseProvenance } from "@skillx/contracts"; +import { ERROR_CODES, SkillTreeError, checkDirectoryAlias, checkPublisher } from "@skillx/skill-tree"; +import { releaseError } from "./release-http"; + +/** Maps a shared tree rule failure to its HTTP status: reserved names are 403, the rest 400. */ +export function treeErrorToHttp(error: SkillTreeError) { + const reserved = error.code === ERROR_CODES.identityReservedPublisher || error.code === ERROR_CODES.identityReservedAlias; + return releaseError(reserved ? 403 : 400, error.code, error.message); +} + +const invalidMetadata = (message: string) => releaseError(400, "release.invalid_metadata", message); + +/** A kit release name: `@agentkit/*` only (else 400 `identity.invalid`). */ +export function parseKitReleaseName(scope: string, slug: string): { identity: string; handle: string; slug: string } { + const identity = `${scope}/${slug}`; + if (!PACKAGE_NAME_PATTERN.test(identity)) { + throw releaseError(400, ERROR_CODES.identityInvalid, "package name must be @publisher/slug in lowercase"); + } + try { + checkPublisher(identity, "kit"); + checkDirectoryAlias(identity, slug); + } catch (error) { + if (error instanceof SkillTreeError) throw treeErrorToHttp(error); + throw error; + } + return { identity, handle: scope.slice(1), slug }; +} + +/** The `provenance` metadata member; null (absent or provider `skillx`) means a v1 release. */ +export function parseProvenance(value: unknown): ReleaseProvenance | null { + if (value === undefined || value === null) return null; + if (typeof value !== "object" || Array.isArray(value)) throw invalidMetadata("provenance must be an object or null"); + const raw = value as Record; + const allowed = raw.provider === "kit" ? ["provider", "origin"] : ["provider"]; + const unknown = Object.keys(raw).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) throw invalidMetadata(`unknown provenance field: ${unknown.join(", ")}`); + if (raw.provider === "skillx") return null; + if (raw.provider !== "kit") throw invalidMetadata(`provenance.provider must be one of: ${RELEASE_PROVIDERS.join(", ")}`); + if (typeof raw.origin !== "string" || !(KIT_ORIGINS as readonly string[]).includes(raw.origin)) { + throw invalidMetadata(`provenance.origin must be one of: ${KIT_ORIGINS.join(", ")}`); + } + return { provider: "kit", origin: raw.origin as KitOrigin }; +} + +/** + * Refuses a kit replay whose provenance differs from the stored signed + * manifest, so a republish never silently reports success for an origin the + * registry did not sign. An unreadable stored manifest is a conflict too. + */ +export async function assertSameKitProvenance( + bucket: R2Bucket, + manifestKey: string | null, + provenance: ReleaseProvenance, + label: string, +): Promise { + const object = manifestKey === null ? null : await bucket.get(manifestKey); + let stored: ReleaseProvenance | undefined; + try { + if (object !== null) stored = (JSON.parse(new TextDecoder().decode(await object.arrayBuffer())) as { provenance?: ReleaseProvenance }).provenance; + } catch { + stored = undefined; + } + if (stored?.provider === "kit" && stored.origin === (provenance as { origin: KitOrigin }).origin) return; + const origin = stored?.provider === "kit" ? stored.origin : "none"; + throw releaseError(409, "release.version_exists", `${label} already exists with kit origin ${origin}`); +} diff --git a/apps/web/app/lib/releases/release-request.ts b/apps/web/app/lib/releases/release-request.ts index 54d57f8..a56d3d9 100644 --- a/apps/web/app/lib/releases/release-request.ts +++ b/apps/web/app/lib/releases/release-request.ts @@ -4,9 +4,10 @@ * here touches storage. */ import { PACKAGE_KINDS, PACKAGE_NAME_PATTERN, RELEASE_CHANNELS, RELEASE_VERSION_PATTERN } from "@skillx/contracts"; -import type { PackageKind, ReleaseChannel, ReleaseSourceProvenance } from "@skillx/contracts"; +import type { PackageKind, ReleaseChannel, ReleaseProvenance, ReleaseProvider, ReleaseSourceProvenance } from "@skillx/contracts"; import { ERROR_CODES, SkillTreeError, checkDirectoryAlias, checkPublisher, validateIdentity } from "@skillx/skill-tree"; import { releaseError } from "./release-http"; +import { parseKitReleaseName, parseProvenance, treeErrorToHttp } from "./release-kit"; import { MAX_RELEASE_METADATA_HEADER_BYTES } from "./release-limits"; export const RELEASE_METADATA_HEADER = "X-SkillX-Release"; @@ -24,27 +25,27 @@ export interface ReleaseMetadata { kind: PackageKind; sourceProvenance: ReleaseSourceProvenance | null; declaredScripts: string[]; + /** `kit` releases are signed as `skillx.release/v2`; null means provider `skillx` (v1). */ + provenance: ReleaseProvenance | null; } -const METADATA_FIELDS = new Set(["version", "channel", "kind", "sourceProvenance", "declaredScripts"]); +const METADATA_FIELDS = new Set(["version", "channel", "kind", "sourceProvenance", "declaredScripts", "provenance"]); /** The AgentKit alias prefixes (`ak-plan`, `ak:plan`), compared with ASCII case folding. */ const RESERVED_ALIAS_PREFIX = /^ak[-:]/i; -function treeErrorToHttp(error: SkillTreeError) { - const reserved = error.code === ERROR_CODES.identityReservedPublisher || error.code === ERROR_CODES.identityReservedAlias; - return releaseError(reserved ? 403 : 400, error.code, error.message); -} - /** - * Parses `@handle/slug` from the route. `@agentkit/*` and the `ak-`/`ak:` - * alias forms are reserved for AgentKit kit content and fail with 403 before - * any other check; a malformed name fails with 400. + * Parses `@handle/slug` from the route for a release of `provider`. For + * `skillx`, `@agentkit/*` and the `ak-`/`ak:` alias forms are reserved for + * AgentKit kit content and fail with 403 before any other check. A `kit` + * release must be `@agentkit/*` (else 400 `identity.invalid`). A malformed + * name fails with 400. */ -export function parseReleaseName(scope: string | undefined, slug: string | undefined): ReleaseName { +export function parseReleaseName(scope: string | undefined, slug: string | undefined, provider: ReleaseProvider = "skillx"): ReleaseName { if (!scope?.startsWith("@") || !slug) { throw releaseError(400, ERROR_CODES.identityInvalid, "package name must be @publisher/slug"); } const handle = scope.slice(1); + if (provider === "kit") return parseKitReleaseName(scope, slug); if (handle.toLowerCase() === "agentkit") { throw releaseError(403, ERROR_CODES.identityReservedPublisher, "the @agentkit publisher is reserved for AgentKit kit content"); } @@ -92,6 +93,22 @@ function oneOf(values: readonly T[], value: unknown, fallback: throw invalidMetadata(`${field} must be one of: ${values.join(", ")}`); } +/** + * The provider a release request asks for, read before authorization so the + * reserved `@agentkit` namespace is decided first. Only `provenance.provider` + * is read; a header that does not decode counts as `skillx`, and + * `parseReleaseMetadata` reports every invalid value later. + */ +export function requestedProvider(header: string | null): ReleaseProvider { + try { + const provenance = decodeMetadataHeader(header).provenance; + const kit = typeof provenance === "object" && provenance !== null && (provenance as Record).provider === "kit"; + return kit ? "kit" : "skillx"; + } catch { + return "skillx"; + } +} + /** * Reads the metadata header. Only the shape is checked here; the signed * manifest validator re-checks every value (provenance URL and commit, @@ -118,6 +135,7 @@ export function parseReleaseMetadata(header: string | null): ReleaseMetadata { kind: oneOf(PACKAGE_KINDS, raw.kind, "skill", "kind"), sourceProvenance: provenance as ReleaseSourceProvenance | null, declaredScripts: scripts as string[], + provenance: parseProvenance(raw.provenance), }; } diff --git a/apps/web/app/lib/releases/release-tree-read.ts b/apps/web/app/lib/releases/release-tree-read.ts new file mode 100644 index 0000000..400872b --- /dev/null +++ b/apps/web/app/lib/releases/release-tree-read.ts @@ -0,0 +1,37 @@ +/** Reading an uploaded release tar into its tree manifest and file contents, before anything is stored. */ +import { ERROR_CODES, SkillTreeError, readTarTree } from "@skillx/skill-tree"; +import type { TreeManifest } from "@skillx/skill-tree"; +import { releaseError } from "./release-http"; +import { RELEASE_SERVER_TREE_LIMITS } from "./release-limits"; + +export interface ReadTree { + tree: TreeManifest; + contents: Map; +} + +export async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** Streams the uncompressed tar through the shared reader; every unsafe entry fails here, before storage. */ +export async function readTree(body: Uint8Array): Promise { + const seen = new Map(); + try { + const tree = await readTarTree(body, { + compression: "none", + limits: RELEASE_SERVER_TREE_LIMITS, + onFileContent: (path, content) => void seen.set(path, content), + }); + const contents = new Map(); + for (const file of tree.files) { + const bytes = seen.get(file.path); + if (bytes === undefined || bytes.length !== file.size) throw new Error(`tree reader lost the content of ${file.path}`); + contents.set(file.path, bytes); + } + return { tree, contents }; + } catch (error) { + if (!(error instanceof SkillTreeError)) throw error; + throw releaseError(error.code === ERROR_CODES.limitExceeded ? 413 : 422, error.code, error.message); + } +} diff --git a/apps/web/app/lib/releases/sign-manifest.ts b/apps/web/app/lib/releases/sign-manifest.ts index 59c5e16..ab5e757 100644 --- a/apps/web/app/lib/releases/sign-manifest.ts +++ b/apps/web/app/lib/releases/sign-manifest.ts @@ -1,5 +1,7 @@ /** - * Registry countersigning of `skillx.release/v1` manifests. The Ed25519 key + * Registry countersigning of release manifests. A kit release is signed as + * `skillx.release/v2` with its `provenance`; every other release stays + * `skillx.release/v1`, so existing v1-only verifiers keep reading it. The Ed25519 key * is the Worker secret `SKILLX_REGISTRY_SIGNING_KEY` (base64 PKCS #8 DER) and * its published ID is `SKILLX_REGISTRY_SIGNING_KEY_ID`. The key is imported * non-extractable; no error, log line or response ever includes it. @@ -8,10 +10,11 @@ import { RELEASE_KEY_ID_PATTERN, RELEASE_SIGNATURE_ALGORITHM, SKILLX_RELEASE_SCHEMA_VERSION, + SKILLX_RELEASE_SCHEMA_VERSION_V2, canonicalJsonBytes, signReleaseManifest, } from "@skillx/contracts"; -import type { SkillxReleaseManifest, UnsignedReleaseManifest } from "@skillx/contracts"; +import type { ReleaseProvenance, SkillxReleaseManifest, UnsignedReleaseManifest, UnsignedReleaseManifestV1 } from "@skillx/contracts"; import { releaseError } from "./release-http"; export interface RegistrySigner { @@ -50,7 +53,10 @@ export async function loadRegistrySigner(env: SigningEnv): Promise; +export type ManifestFields = Omit & { + /** `kit` signs v2; `skillx` (or none) signs v1, whose provider is implicitly `skillx`. */ + provenance: ReleaseProvenance | null; +}; /** RFC 3339 UTC with whole seconds, as the manifest contract requires. */ export function releaseTimestamp(now: Date): string { @@ -66,13 +72,12 @@ export async function signRelease( signer: RegistrySigner, now: Date, ): Promise<{ manifest: SkillxReleaseManifest; bytes: Uint8Array }> { - const unsigned: UnsignedReleaseManifest = { - schemaVersion: SKILLX_RELEASE_SCHEMA_VERSION, - ...fields, - createdAt: releaseTimestamp(now), - keyId: signer.keyId, - signatureAlgorithm: RELEASE_SIGNATURE_ALGORITHM, - }; + const { provenance, ...members } = fields; + const signing = { createdAt: releaseTimestamp(now), keyId: signer.keyId, signatureAlgorithm: RELEASE_SIGNATURE_ALGORITHM } as const; + const unsigned: UnsignedReleaseManifest = + provenance?.provider === "kit" + ? { schemaVersion: SKILLX_RELEASE_SCHEMA_VERSION_V2, ...members, provenance, ...signing } + : { schemaVersion: SKILLX_RELEASE_SCHEMA_VERSION, ...members, ...signing }; const manifest = await signReleaseManifest(unsigned, signer.privateKey); return { manifest, bytes: canonicalJsonBytes(manifest) }; } diff --git a/apps/web/app/routes/docs-publishers.tsx b/apps/web/app/routes/docs-publishers.tsx index 8763511..94a578c 100644 --- a/apps/web/app/routes/docs-publishers.tsx +++ b/apps/web/app/routes/docs-publishers.tsx @@ -154,6 +154,14 @@ export default function DocsPublishers() { publishing a skill registers and scans it without inventing a release history it cannot prove.

+

+ The @agentkit publisher and the{" "} + ak- slug prefix are reserved for AgentKit kit + content. Those releases carry kit provenance and are signed as{" "} + skillx.release/v2. The registry signature never + makes them signed-kit content. Every other release is signed as{" "} + skillx.release/v1. +

diff --git a/apps/web/tests/releases/release-kit-publish.test.ts b/apps/web/tests/releases/release-kit-publish.test.ts new file mode 100644 index 0000000..ff4f36f --- /dev/null +++ b/apps/web/tests/releases/release-kit-publish.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { releaseTrust, validateReleaseManifest, verifyReleaseManifest, verifyReleaseStatus } from "@skillx/contracts"; +import type { SkillxReleaseManifest } from "@skillx/contracts"; +import { loader as manifestLoader } from "~/routes/api.v1.release-manifest"; +import { FakeRateLimiter } from "./fake-rate-limiter"; +import { RELEASE_READ_RATE_LIMIT } from "~/lib/releases/release-rate-limit"; +import { readReleaseStatus } from "~/lib/releases/release-status-issue"; +import { NOW, createHarness, file, tarOf, TEST_KEY_ID } from "./release-harness"; + +/** Kit releases: `@agentkit/*` content published with kit provenance and signed as `skillx.release/v2`. */ + +type Harness = Awaited>; +const KIT = { provider: "kit", origin: "remote-signed" } as const; +const skillFiles = () => [file("SKILL.md", "---\nname: review\ndescription: Review guardrails\n---\n# Review\n")]; +const kitMetadata = (provenance: unknown = KIT) => ({ version: "1.0.0", provenance }); + +let h: Harness; +let agentkitKey: string; +beforeEach(async () => { + h = await createHarness(); + agentkitKey = await h.createKey("user-a", ["releases:publish:@agentkit"]); +}); + +const storedManifest = (): { bytes: Uint8Array; manifest: SkillxReleaseManifest } => { + const [key] = h.r2.keys("manifests/"); + const bytes = h.r2.objects.get(key!)!.bytes as Uint8Array; + return { bytes, manifest: JSON.parse(new TextDecoder().decode(bytes)) }; +}; + +describe("kit release publish", () => { + it("signs an @agentkit kit release as v2 with its provenance, and the trust stays local-unverified", async () => { + const res = await h.upload("@agentkit/review", tarOf(skillFiles()), { key: agentkitKey, metadata: kitMetadata() }); + expect(res).toMatchObject({ status: 200, body: { created: true, state: "published" } }); + + const { bytes, manifest } = storedManifest(); + expect(await verifyReleaseManifest(bytes, new Map([[TEST_KEY_ID, h.signingKey.publicKey]]))).toEqual({ valid: true, issues: [] }); + expect(manifest).toMatchObject({ + schemaVersion: "skillx.release/v2", + identity: "@agentkit/review", + publisher: { handle: "agentkit", verifiedBy: "admin" }, + provenance: KIT, + }); + expect(releaseTrust(manifest)).toBe("local-unverified"); + const [row] = h.d1.raw.prepare("SELECT manifest_schema_version FROM package_releases").all() as Array>; + expect(row).toEqual({ manifest_schema_version: "skillx.release/v2" }); + }); + + it("keeps an ordinary release on v1, also when it names provider skillx", async () => { + const res = await h.upload("@alpha/demo", tarOf(skillFiles()), { metadata: kitMetadata({ provider: "skillx" }) }); + expect(res.status).toBe(200); + const { manifest } = storedManifest(); + expect(manifest.schemaVersion).toBe("skillx.release/v1"); + expect(manifest).not.toHaveProperty("provenance"); + }); + + it("serves the stored v2 manifest from the read API unchanged", async () => { + const res = await h.upload("@agentkit/review", tarOf(skillFiles()), { key: agentkitKey, metadata: kitMetadata() }); + const env = { + SKILLX_RELEASES_API: "1", + DB: h.deps.db, + R2: h.deps.bucket, + RELEASE_READ_LIMITER: new FakeRateLimiter(RELEASE_READ_RATE_LIMIT), + SKILLX_REGISTRY_SIGNING_KEY_ID: TEST_KEY_ID, + }; + const digest = res.body.releaseDigest as string; + const request = new Request(`https://skillx.test/api/v1/releases/${digest}/manifest`); + const served = await manifestLoader({ request, params: { digest }, context: { cloudflare: { env } } } as never); + expect(served.status).toBe(200); + const bytes = new Uint8Array(await served.arrayBuffer()); + expect(bytes).toEqual(storedManifest().bytes); + expect(validateReleaseManifest(JSON.parse(new TextDecoder().decode(bytes))).valid).toBe(true); + }); + + it("still reserves @agentkit for a release without kit provenance", async () => { + const res = await h.upload("@agentkit/review", tarOf(skillFiles()), { key: agentkitKey }); + expect(res).toMatchObject({ status: 403, body: { error: { code: "identity.reserved_publisher" } } }); + expect(h.r2.putLog).toEqual([]); + }); + + it("refuses kit provenance outside the @agentkit publisher", async () => { + const res = await h.upload("@bestagentkits/review", tarOf(skillFiles()), { metadata: kitMetadata() }); + expect(res).toMatchObject({ status: 400, body: { error: { code: "identity.invalid" } } }); + expect(h.r2.putLog).toEqual([]); + }); + + it("lets a kit release use the ak- alias form reserved for @agentkit", async () => { + const res = await h.upload("@agentkit/ak-review", tarOf(skillFiles()), { key: agentkitKey, metadata: kitMetadata() }); + expect(res.status).toBe(200); + }); + + it("requires the releases:publish:@agentkit scope of the agentkit publisher owner", async () => { + const other = await h.createKey("user-a", ["releases:publish:@alpha"]); + const scoped = await h.upload("@agentkit/review", tarOf(skillFiles()), { key: other, metadata: kitMetadata() }); + expect(scoped).toMatchObject({ status: 403, body: { error: { code: "auth.scope_missing" } } }); + const stranger = await h.createKey("user-b", ["releases:publish:@agentkit"]); + const notOwner = await h.upload("@agentkit/review", tarOf(skillFiles()), { key: stranger, metadata: kitMetadata() }); + expect(notOwner.body.error.code).toBe("publisher.not_owner"); + h.d1.raw.prepare("UPDATE publishers SET state = 'suspended' WHERE handle = 'agentkit'").run(); + const inactive = await h.upload("@agentkit/review", tarOf(skillFiles()), { key: agentkitKey, metadata: kitMetadata() }); + expect(inactive.body.error.code).toBe("publisher.inactive"); + expect(h.r2.putLog).toEqual([]); + }); + + it("rejects an unknown kit origin with 400", async () => { + const res = await h.upload("@agentkit/review", tarOf(skillFiles()), { + key: agentkitKey, + metadata: kitMetadata({ provider: "kit", origin: "registry-signed" }), + }); + expect(res).toMatchObject({ status: 400, body: { error: { code: "release.invalid_metadata" } } }); + }); + + it("answers an identical kit replay with 200 and a different origin with 409", async () => { + const publish = (origin: string) => + h.upload("@agentkit/review", tarOf(skillFiles()), { key: agentkitKey, metadata: kitMetadata({ provider: "kit", origin }) }); + expect((await publish("local-dev")).body.created).toBe(true); + expect(await publish("local-dev")).toMatchObject({ status: 200, body: { created: false } }); + const upgraded = await publish("remote-signed"); + expect(upgraded).toMatchObject({ status: 409, body: { error: { code: "release.version_exists" } } }); + expect(upgraded.body.error.message).toContain("kit origin local-dev"); + }); + + it("rejects a kit provenance without origin after authorization", async () => { + const res = await h.upload("@agentkit/review", tarOf(skillFiles()), { key: agentkitKey, metadata: kitMetadata({ provider: "kit" }) }); + expect(res).toMatchObject({ status: 400, body: { error: { code: "release.invalid_metadata" } } }); + expect(h.r2.putLog).toEqual([]); + }); + + it("issues a signed release status that verifies against the v2 manifest", async () => { + await h.upload("@agentkit/review", tarOf(skillFiles()), { key: agentkitKey, metadata: kitMetadata() }); + const request = new Request("https://skillx.test/api/v1/packages/@agentkit/review/releases/1.0.0/status"); + const ctx = { db: h.deps.db, bucket: h.deps.bucket, userId: null, request }; + const now = new Date(NOW.getTime() + 3600_000); + const res = await readReleaseStatus(ctx, { scope: "@agentkit", slug: "review", version: "1.0.0" }, { + loadSigner: h.deps.loadSigner, + activeKeyId: TEST_KEY_ID, + now: () => now, + }); + expect(res.status).toBe(200); + const keys = new Map([[TEST_KEY_ID, h.signingKey.publicKey]]); + const bytes = new Uint8Array(await res.arrayBuffer()); + expect(await verifyReleaseStatus(bytes, keys, { manifest: storedManifest().manifest, now, previous: null })).toEqual({ valid: true, issues: [] }); + }); +}); diff --git a/apps/web/tests/releases/sign-manifest.test.ts b/apps/web/tests/releases/sign-manifest.test.ts index 7d6ab02..07d8d79 100644 --- a/apps/web/tests/releases/sign-manifest.test.ts +++ b/apps/web/tests/releases/sign-manifest.test.ts @@ -3,7 +3,7 @@ import { verifyReleaseManifest, releaseTreeDigest } from "@skillx/contracts"; import { VECTOR_KEY_SEED_TEXT, vectorKey } from "../../../../packages/contracts/src/test-support/vector-key"; import { ReleaseHttpError } from "~/lib/releases/release-http"; import { loadRegistrySigner, signRelease } from "~/lib/releases/sign-manifest"; -import { parseReleaseMetadata, parseReleaseName } from "~/lib/releases/release-request"; +import { parseReleaseMetadata, parseReleaseName, requestedProvider } from "~/lib/releases/release-request"; import { normalizeRequestedScopes, parseStoredScopes } from "~/lib/auth/api-key-scopes"; const PKCS8_PREFIX = [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]; @@ -33,6 +33,7 @@ describe("registry signer", () => { publisher: { handle: "alpha", verifiedBy: "admin" }, sourceProvenance: null, declaredScripts: [], + provenance: null, }, signer, new Date("2026-10-13T12:34:56.789Z"), @@ -73,6 +74,7 @@ describe("request boundary", () => { kind: "skill", sourceProvenance: null, declaredScripts: [], + provenance: null, }); expect(() => parseReleaseMetadata(null)).toThrow(/required/); expect(() => parseReleaseMetadata("%%%")).toThrow(/base64/); @@ -80,6 +82,27 @@ describe("request boundary", () => { expect(() => parseReleaseMetadata(header({ version: "1.0.0", channel: "nightly" }))).toThrow(/channel/); expect(() => parseReleaseMetadata(header({ version: "1.0.0", owner: "x" }))).toThrow(/unknown/); }); + + it("decodes kit provenance and treats provider skillx as a v1 release", () => { + const header = (provenance: unknown) => Buffer.from(JSON.stringify({ version: "1.0.0", provenance })).toString("base64"); + const kit = { provider: "kit", origin: "remote-signed" }; + expect(parseReleaseMetadata(header(kit)).provenance).toEqual(kit); + expect(parseReleaseMetadata(header({ provider: "skillx" })).provenance).toBeNull(); + expect(requestedProvider(header(kit))).toBe("kit"); + expect(requestedProvider("%%%")).toBe("skillx"); + expect(requestedProvider(header({ provider: "kit", origin: "signed" }))).toBe("kit"); + expect(() => parseReleaseMetadata(header({ provider: "kit" }))).toThrow(/origin/); + expect(() => parseReleaseMetadata(header({ provider: "kit", origin: "signed" }))).toThrow(/origin/); + expect(() => parseReleaseMetadata(header({ provider: "git" }))).toThrow(/provider/); + expect(() => parseReleaseMetadata(header({ provider: "skillx", origin: "local-dev" }))).toThrow(/unknown provenance/); + expect(() => parseReleaseMetadata(header("kit"))).toThrow(/object/); + }); + + it("accepts only @agentkit names for kit releases", () => { + expect(parseReleaseName("@agentkit", "review", "kit")).toEqual({ identity: "@agentkit/review", handle: "agentkit", slug: "review" }); + expect(() => parseReleaseName("@alpha", "review", "kit")).toThrow(/kit/); + expect(() => parseReleaseName("@AgentKit", "review", "kit")).toThrow(/lowercase/); + }); }); describe("api key scopes", () => { diff --git a/docs/api-reference.md b/docs/api-reference.md index 64c41b7..e1fc41a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -747,7 +747,7 @@ Bulk upsert skills and index into Vectorize. For seeding demo/production data. --- -## Release Manifest Contract (`skillx.release/v1`) +## Release Manifest Contract (`skillx.release/v1`, `skillx.release/v2`) Contract only: no endpoint serves or accepts release manifests yet. The publisher pipeline and pinned read API build on it. Source: @@ -762,8 +762,8 @@ members; unknown members (including `state`) are rejected at every level. | Member | Type | Rule | |--------|------|------| -| `schemaVersion` | string | `skillx.release/v1` | -| `identity` | string | `PACKAGE_NAME_PATTERN`, publisher and slug pass the tree segment rules; `@agentkit/*` is rejected (`identity.reserved_publisher`) | +| `schemaVersion` | string | `skillx.release/v1` or `skillx.release/v2` (any other value is `unsupported_value`) | +| `identity` | string | `PACKAGE_NAME_PATTERN`, publisher and slug pass the tree segment rules; `@agentkit/*` is rejected (`identity.reserved_publisher`) unless the v2 provider is `kit` (see Kit provenance) | | `version` | string | SemVer 2.0 without build metadata (`+...`) or leading zeros in numeric identifiers, at most 128 characters (`RELEASE_VERSION_PATTERN`; stricter than the package-wide `SEMVER_PATTERN`) | | `channel` | string | `stable`, `beta`, `alpha` | | `kind` | string | `skill`, `hook-pack`, `bundle` | @@ -781,6 +781,30 @@ members; unknown members (including `state`) are rejected at every level. | `signatureAlgorithm` | string | `ed25519` | | `signature` | string | padded standard base64 of the 64-byte Ed25519 signature: `^[A-Za-z0-9+/]{85}[AQgw]==$` (the unused trailing bits are zero, so each signature has one accepted text) | +### Kit provenance (`skillx.release/v2`) + +`skillx.release/v2` is v1 plus one required, closed member, `provenance`. +v1 has no such member (`unknown_field`) and every v1 release is provider +`skillx`. The registry signs kit releases as v2 and keeps every other release +on v1, so v1-only verifiers keep working. Source: +`packages/contracts/src/release-provenance.ts`. + +| `provenance` | Rule | +|--------------|------| +| `{"provider": "skillx"}` | identity must not be `@agentkit/*` (`identity.reserved_publisher`); the slug must not start with `ak-` or `ak:` (`identity.reserved_alias`) | +| `{"provider": "kit", "origin": "remote-signed" \| "local-dev"}` | identity must be `@agentkit/*` (`identity.invalid`); `origin` records where the kit content came from | + +Other providers or origins are `unsupported_value`, a missing `origin` is +`required` and extra members are `unknown_field`. `origin` is a claim that +the registry countersigns, not a kit signature: a verifier MUST NOT derive +trust `signed-kit` from a SkillX signature. `releaseTrust(manifest, +{kitVerifiedReleaseDigest})` returns `signed-kit` only for `origin: +"remote-signed"` when the caller verified the AgentKit kit signature of the +tree whose digest equals the manifest `releaseDigest`; every other release is +`local-unverified`. Golden vectors: +`packages/contracts/src/fixtures/release-manifest-v2.json` (same test key and +canonical form as the v1 vectors). + ### Signature bytes The signature covers the UTF-8 bytes of the canonical JSON of the manifest @@ -968,17 +992,28 @@ Unscoped keys are rejected (`auth.scope_missing`). `version` (SemVer without build metadata, required), `channel` (`stable` default, `beta`, `alpha`), `kind` (`skill` default, `hook-pack`, `bundle`), `sourceProvenance` (`{repo, commit, path}` or `null`) and - `declaredScripts` (packaged paths). Unknown members are rejected. + `declaredScripts` (packaged paths) and `provenance` (`null`, + `{"provider": "skillx"}`, or `{"provider": "kit", "origin": + "remote-signed" | "local-dev"}`). Unknown members are rejected. `sourceProvenance` is asserted by the publisher and not verified (GitHub OIDC is a follow-up); the registry signature covers it but does not vouch for it. +**Kit releases**: `@agentkit/*` is accepted only with `provenance.provider += "kit"`, from a key scoped `releases:publish:@agentkit` whose user owns the +active, admin-provisioned `agentkit` publisher (`POST /api/v1/publishers`). +They are signed as `skillx.release/v2`; the `ak-` slug forms are allowed for +them. Kit provenance on any other publisher is `400 identity.invalid`; every +release without it is signed as `skillx.release/v1`. Republishing an existing +kit version with the same tree but a different `origin` is `409 +release.version_exists`. + **Processing**, each step rejecting before the next runs: name and reserved namespaces, key and ownership, signing key, metadata, capped body, tar read with the shared skill-tree rules and digest (traversal, absolute paths, symlinks, hardlinks, devices and duplicate paths fail), version conflict, the exec manifest check (below), the release scan, the signed `skillx.release/v1` -manifest, then R2 writes, and the D1 row last. Uploaded bytes are stored +(or, for a kit release, `skillx.release/v2`) manifest, then R2 writes, and the D1 row last. Uploaded bytes are stored exactly; nothing is sanitized. **Exec manifest** (`agentkit-exec.json`, optional). A tree may carry an @@ -1076,7 +1111,7 @@ index: one row wins and the loser gets the same idempotent `200` or `409`. |--------|-------| | 400 | `identity.*` (invalid names), `release.invalid_metadata`, `tree.read_failed` (empty body) | | 401 | `auth.invalid_key` | -| 403 | `identity.reserved_publisher` (`@agentkit`), `identity.reserved_alias` (`ak-`/`ak:` slugs), `auth.scope_missing`, `publisher.not_owner`, `publisher.inactive` | +| 403 | `identity.reserved_publisher` (`@agentkit` without kit provenance), `identity.reserved_alias` (`ak-`/`ak:` slugs), `auth.scope_missing`, `publisher.not_owner`, `publisher.inactive` | | 404 | `not_found` (API disabled) | | 409 | `release.version_exists`, `release.kind_conflict` | | 413 | `tree.limit_exceeded` | @@ -1099,7 +1134,7 @@ Read-only, behind the same flag: unless `SKILLX_RELEASES_API` is `"1"` or | Endpoint | Returns | |----------|---------| | `GET /api/v1/packages/@{publisher}/{slug}/releases` | JSON list, newest first (at most 500): `version`, `channel`, `state`, `releaseDigest`, `artifactDigest`, `artifactSize`, `createdAt`. Published and yanked releases; a release is hidden when its tree is quarantined anywhere (it would answer `403`), so the list never offers one | -| `GET /api/v1/packages/@{publisher}/{slug}/releases/{version}` | The signed `skillx.release/v1` manifest bytes (new resolution: only `published`) | +| `GET /api/v1/packages/@{publisher}/{slug}/releases/{version}` | The signed `skillx.release/v1` or `v2` manifest bytes (new resolution: only `published`) | | `GET /api/v1/packages/@{publisher}/{slug}/releases/{version}/status` | The signed `skillx.release-status/v1` document of that release, in every state (see [Signed release status](#signed-release-status)) | | `GET /api/v1/releases/{releaseDigest}/manifest[?version=&package=]` | The same manifest, by tree digest (locked clients) | | `GET /api/v1/releases/{releaseDigest}/files/{path}[?version=&package=]` | One file's raw bytes, only if `path` is in the manifest | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8facf71..b5cb895 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -221,6 +221,7 @@ one packed locally. | `--kind ` | `skill`, `hook-pack` or `bundle` | `skill` | | `--repo ` | Provenance repository URL | `origin` remote (https form) | | `--script ` | Declare a packaged script (repeatable) | — | +| `--kit-origin ` | Publish an `@agentkit/*` kit release (`remote-signed` or `local-dev`), signed as `skillx.release/v2`; needs a `releases:publish:@agentkit` key | — | | `--registry ` | Registry base URL; `https` only (`http` only for localhost) | configured base URL | | `--json` | Print the result as JSON | — | diff --git a/docs/system-architecture.md b/docs/system-architecture.md index c379ff5..6124ccb 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -592,7 +592,7 @@ skillx release pack (git commit -> deterministic tar, tree digest via @skillx/sk POST /api/v1/packages/@h/s/releases ── scoped key ── tar read + digest ── scan │ (published | quarantined) ▼ -R2 (create-only): artifacts/ blobs/ manifests/ (signed skillx.release/v1) +R2 (create-only): artifacts/ blobs/ manifests/ (signed skillx.release/v1; kit releases v2) D1: packages, package_releases (row written last) │ ▼ diff --git a/packages/cli/src/commands/release-publish-provenance.test.ts b/packages/cli/src/commands/release-publish-provenance.test.ts new file mode 100644 index 0000000..f6b15e7 --- /dev/null +++ b/packages/cli/src/commands/release-publish-provenance.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { kitProvenance, releaseMetadataHeader } from './release-publish.js'; + +describe('kitProvenance', () => { + it('sends no provenance without --kit-origin, so the release stays skillx.release/v1', () => { + expect(kitProvenance('alpha', undefined)).toBeUndefined(); + expect(kitProvenance('agentkit', undefined)).toBeUndefined(); + }); + + it('marks an @agentkit release as a kit release with its origin', () => { + expect(kitProvenance('agentkit', 'remote-signed')).toEqual({ provider: 'kit', origin: 'remote-signed' }); + expect(kitProvenance('agentkit', 'local-dev')).toEqual({ provider: 'kit', origin: 'local-dev' }); + }); + + it('refuses an unknown origin and a kit origin for any other publisher', () => { + expect(() => kitProvenance('agentkit', 'signed')).toThrow(/--kit-origin must be one of/); + expect(() => kitProvenance('bestagentkits', 'remote-signed')).toThrow(/only for @agentkit/); + }); +}); + +describe('releaseMetadataHeader', () => { + const decode = (header: string) => JSON.parse(Buffer.from(header, 'base64').toString('utf8')) as Record; + + it('sends the kit provenance in X-SkillX-Release and omits it for a skillx release', () => { + const kit = decode(releaseMetadataHeader({ version: '1.0.0', provenance: kitProvenance('agentkit', 'local-dev') })); + expect(kit).toEqual({ version: '1.0.0', provenance: { provider: 'kit', origin: 'local-dev' } }); + const plain = decode(releaseMetadataHeader({ version: '1.0.0', provenance: kitProvenance('alpha', undefined) })); + expect(plain).toEqual({ version: '1.0.0' }); + }); +}); diff --git a/packages/cli/src/commands/release-publish.ts b/packages/cli/src/commands/release-publish.ts index e1795e2..c41d019 100644 --- a/packages/cli/src/commands/release-publish.ts +++ b/packages/cli/src/commands/release-publish.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { spawnSync } from 'node:child_process'; import chalk from 'chalk'; +import { KIT_ORIGINS } from '@skillx/skill-tree'; import { getApiKey, getBaseUrl } from '../utils/config-store.js'; import { packRelease, packSummary, releasePackCommand, warnPackNotes } from './release-pack.js'; @@ -48,6 +49,26 @@ export function assertRegistryDigests( } } +/** + * The `provenance` metadata of a publish: a kit release (`--kit-origin`) must + * be `@agentkit/*` and is signed as `skillx.release/v2`; without the flag the + * release is provider `skillx` (v1) and `@agentkit/*` stays reserved. + */ +export function kitProvenance(publisher: string, kitOrigin: string | undefined): { provider: 'kit'; origin: string } | undefined { + if (kitOrigin === undefined) return undefined; + if (!(KIT_ORIGINS as readonly string[]).includes(kitOrigin)) { + throw new Error(`--kit-origin must be one of: ${KIT_ORIGINS.join(', ')}`); + } + if (publisher !== 'agentkit') throw new Error('--kit-origin is only for @agentkit/* kit releases'); + return { provider: 'kit', origin: kitOrigin }; +} + +/** The base64 `X-SkillX-Release` header; `provenance` is sent only for a kit release. */ +export function releaseMetadataHeader({ provenance, ...fields }: Record & { provenance?: unknown }): string { + const metadata = provenance === undefined ? fields : { ...fields, provenance }; + return Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64'); +} + function originUrl(repoRoot: string): string | null { const result = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot, encoding: 'utf8' }); return result.status === 0 ? httpsRepoUrl(result.stdout) : null; @@ -60,6 +81,7 @@ interface PublishOptions { channel: string; kind: string; repo?: string; + kitOrigin?: string; script: string[]; registry?: string; json?: boolean; @@ -77,6 +99,7 @@ export const releasePublishCommand = new Command('publish') .option('--channel ', 'stable | beta | alpha', 'stable') .option('--kind ', 'skill | hook-pack | bundle', 'skill') .option('--repo ', 'source repository URL for provenance (default: git remote origin)') + .option('--kit-origin ', 'publish an @agentkit/* kit release with this origin: remote-signed | local-dev (needs a releases:publish:@agentkit key)') .option('--script ', 'declare a packaged file as a script (repeatable)', collect, []) .option('--registry ', 'registry base URL (default: configured base URL)') .option('--json', 'print the result as JSON') @@ -85,6 +108,7 @@ export const releasePublishCommand = new Command('publish') const name = NAME_PATTERN.exec(options.name); if (!name) throw new Error(`--name must be @publisher/slug, got ${JSON.stringify(options.name)}`); const apiKey = getApiKey(); + const provenance = kitProvenance(name[1], options.kitOrigin); if (!apiKey) throw new Error('an API key is required: set SKILLX_API_KEY or run skillx config set api-key '); if (options.repo !== undefined && httpsRepoUrl(options.repo) !== options.repo) { throw new Error('--repo must be an https URL without credentials or a trailing .git'); @@ -96,19 +120,20 @@ export const releasePublishCommand = new Command('publish') const repo = options.repo ?? originUrl(packed.source.repoRoot); if (repo === null) console.error(chalk.yellow('warning: no https origin remote; the release will record no source provenance')); - const metadata = { + const metadata = releaseMetadataHeader({ version: options.releaseVersion, channel: options.channel, kind: options.kind, sourceProvenance: repo === null ? null : { repo, commit: packed.commit, path: packed.path }, declaredScripts: [...options.script].sort(), - }; + provenance, + }); const res = await fetch(`${base}/api/v1/packages/@${name[1]}/${name[2]}/releases`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/x-tar', - 'X-SkillX-Release': Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64'), + 'X-SkillX-Release': metadata, }, body: packed.tar, }); diff --git a/packages/contracts/src/fixtures/release-manifest-v2.json b/packages/contracts/src/fixtures/release-manifest-v2.json new file mode 100644 index 0000000..99d4105 --- /dev/null +++ b/packages/contracts/src/fixtures/release-manifest-v2.json @@ -0,0 +1,899 @@ +{ + "description": "skillx.release/v2 signed manifest vectors (kit provenance). The test key is derived from a public seed text and MUST NOT be trusted outside tests.", + "verifierRules": [ + "canonicalization, tree digest and signature rules are those of skillx.release/v1 (release-manifest-v1.json)", + "schemaVersion selects the closed member set: v1 has no provenance member (unknown_field), v2 requires it (required); any other schemaVersion is unsupported_value", + "provenance is {provider: 'skillx'} or {provider: 'kit', origin: 'remote-signed' | 'local-dev'}; other providers or origins are unsupported_value, extra members unknown_field", + "@agentkit/* requires provider kit (identity.reserved_publisher); provider kit requires @agentkit/* (identity.invalid); a provider skillx slug must not start with ak- (identity.reserved_alias)", + "trust is local-unverified unless the verifier itself verified the AgentKit kit signature of a remote-signed kit release; the registry signature alone never yields signed-kit" + ], + "testKey": { + "keyId": "skillx-test-vector-1", + "seedText": "skillx.release/v1 test vector key; public, never trusted", + "seedHex": "1ec7ec7c27d6e97182ca8a8c0e2f3f2af27f4f7174f70ba70dbfd54f6c12c575", + "publicKeyHex": "8029f998dfaf0fd73e5bdc7762280fbb0a9805af218308d2df9213c64cdfadb2", + "publicKeyBase64": "gCn5mN+vD9c+W9x3YigPuwqYBa8hgwjS35ITxkzfrbI=" + }, + "valid": [ + { + "name": "remote-signed kit release of an @agentkit foundation", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@agentkit/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"origin\":\"remote-signed\",\"provider\":\"kit\"},\"publisher\":{\"handle\":\"agentkit\",\"verifiedBy\":\"admin\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v2\",\"signature\":\"2lFLIZKg9EziM/aeisQwXUzCIzAkh4hrIkCfasqTaXz6Fun1TXSF3zQpgMJ5720+D4tiwb/KL4a4J0KaEXhsCw==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"kits/core/skills/ak-review-guardrails\",\"repo\":\"https://github.com/bestagentkits/agentkit\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit", + "origin": "remote-signed" + }, + "signature": "2lFLIZKg9EziM/aeisQwXUzCIzAkh4hrIkCfasqTaXz6Fun1TXSF3zQpgMJ5720+D4tiwb/KL4a4J0KaEXhsCw==" + }, + "signingPayload": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@agentkit/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"origin\":\"remote-signed\",\"provider\":\"kit\"},\"publisher\":{\"handle\":\"agentkit\",\"verifiedBy\":\"admin\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v2\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"kits/core/skills/ak-review-guardrails\",\"repo\":\"https://github.com/bestagentkits/agentkit\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "treeSerialization": "agentkit.skill-tree/v1\n100644 bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11 102 SKILL.md\n100644 d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f 34 references/café & notes
.md\n100755 b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c 18 scripts/check.sh\n", + "fileContentsBase64": { + "SKILL.md": "LS0tCm5hbWU6IHJldmlldy1ndWFyZHJhaWxzCmRlc2NyaXB0aW9uOiBSZXZpZXcgZ3VhcmRyYWlscyBmb3IgcHVsbCByZXF1ZXN0cy4KLS0tCiMgUmV2aWV3IGd1YXJkcmFpbHMK", + "references/café & notes
.md": "Tm90ZXMgd2l0aCBhIGxpbmUgc2VwYXJhdG9yIHBhdGguCg==", + "scripts/check.sh": "IyEvYmluL3NoCmVjaG8gb2sK" + }, + "expectedTrust": "local-unverified" + }, + { + "name": "local-dev kit release", + "document": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":1,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d\",\"size\":44}],\"identity\":\"@agentkit/tiny_skill\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"origin\":\"local-dev\",\"provider\":\"kit\"},\"publisher\":{\"handle\":\"agentkit\",\"verifiedBy\":\"admin\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v2\",\"signature\":\"LMiRSs74g+F+d1KQtYM2Prs/0HCSlsKJxZ4Xw1e0DHvl6YLAzsk3+jsYYaUitmVrpwxKYZUVdFUp4uCwl/DhCQ==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":44,\"version\":\"0.1.0-beta.1\"}", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/tiny_skill", + "version": "0.1.0-beta.1", + "channel": "beta", + "kind": "skill", + "releaseDigest": "sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544", + "artifactDigest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "artifactSize": 1536, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d", + "size": 44 + } + ], + "fileCount": 1, + "treeSize": 44, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": null, + "declaredScripts": [], + "createdAt": "2026-10-13T05:00:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit", + "origin": "local-dev" + }, + "signature": "LMiRSs74g+F+d1KQtYM2Prs/0HCSlsKJxZ4Xw1e0DHvl6YLAzsk3+jsYYaUitmVrpwxKYZUVdFUp4uCwl/DhCQ==" + }, + "signingPayload": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":1,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d\",\"size\":44}],\"identity\":\"@agentkit/tiny_skill\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"origin\":\"local-dev\",\"provider\":\"kit\"},\"publisher\":{\"handle\":\"agentkit\",\"verifiedBy\":\"admin\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v2\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":44,\"version\":\"0.1.0-beta.1\"}", + "treeSerialization": "agentkit.skill-tree/v1\n100644 7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d 44 SKILL.md\n", + "fileContentsBase64": { + "SKILL.md": "LS0tCm5hbWU6IHRpbnkKZGVzY3JpcHRpb246IFRpbnkgc2tpbGwuCi0tLQo=" + }, + "expectedTrust": "local-unverified" + }, + { + "name": "skillx release written as v2", + "document": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":1,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d\",\"size\":44}],\"identity\":\"@solo.dev/tiny_skill\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"provider\":\"skillx\"},\"publisher\":{\"handle\":\"solo.dev\",\"verifiedBy\":\"none\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v2\",\"signature\":\"wZ97J9xoI8gmzYRLl1wF16dQfh4VpVBrSPh0LUg2m5+q5Qj1Wi/Ex57TjoUCqB26hu6izX4NmO/a/wOmxJbWAA==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":44,\"version\":\"0.1.0-beta.1\"}", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@solo.dev/tiny_skill", + "version": "0.1.0-beta.1", + "channel": "beta", + "kind": "skill", + "releaseDigest": "sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544", + "artifactDigest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "artifactSize": 1536, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d", + "size": 44 + } + ], + "fileCount": 1, + "treeSize": 44, + "publisher": { + "handle": "solo.dev", + "verifiedBy": "none" + }, + "sourceProvenance": null, + "declaredScripts": [], + "createdAt": "2026-10-13T05:00:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "skillx" + }, + "signature": "wZ97J9xoI8gmzYRLl1wF16dQfh4VpVBrSPh0LUg2m5+q5Qj1Wi/Ex57TjoUCqB26hu6izX4NmO/a/wOmxJbWAA==" + }, + "signingPayload": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":1,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d\",\"size\":44}],\"identity\":\"@solo.dev/tiny_skill\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"provider\":\"skillx\"},\"publisher\":{\"handle\":\"solo.dev\",\"verifiedBy\":\"none\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v2\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":44,\"version\":\"0.1.0-beta.1\"}", + "treeSerialization": "agentkit.skill-tree/v1\n100644 7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d 44 SKILL.md\n", + "fileContentsBase64": { + "SKILL.md": "LS0tCm5hbWU6IHRpbnkKZGVzY3JpcHRpb246IFRpbnkgc2tpbGwuCi0tLQo=" + }, + "expectedTrust": "local-unverified" + } + ], + "rejected": [ + { + "name": "origin upgraded from local-dev to remote-signed after signing", + "document": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":1,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d\",\"size\":44}],\"identity\":\"@agentkit/tiny_skill\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"origin\":\"remote-signed\",\"provider\":\"kit\"},\"publisher\":{\"handle\":\"agentkit\",\"verifiedBy\":\"admin\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v2\",\"signature\":\"LMiRSs74g+F+d1KQtYM2Prs/0HCSlsKJxZ4Xw1e0DHvl6YLAzsk3+jsYYaUitmVrpwxKYZUVdFUp4uCwl/DhCQ==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":44,\"version\":\"0.1.0-beta.1\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "provenance changed from kit to skillx with a non-agentkit identity after signing", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@bestagentkits/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"provider\":\"skillx\"},\"publisher\":{\"handle\":\"bestagentkits\",\"verifiedBy\":\"admin\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v2\",\"signature\":\"2lFLIZKg9EziM/aeisQwXUzCIzAkh4hrIkCfasqTaXz6Fun1TXSF3zQpgMJ5720+D4tiwb/KL4a4J0KaEXhsCw==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"kits/core/skills/ak-review-guardrails\",\"repo\":\"https://github.com/bestagentkits/agentkit\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "v1 manifest relabelled v2 with provenance after signing", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"provider\":\"skillx\"},\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v2\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "@agentkit release signed as v1 without provenance", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@agentkit/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"agentkit\",\"verifiedBy\":\"admin\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"W34Yw3/BbnSQS+72UC5qUoFi8Zqza+QDeflmPWNqNVCXangThgpY9JMdKkyqdABht4cxye1a+9l5dHSQtKk+Bg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"kits/core/skills/ak-review-guardrails\",\"repo\":\"https://github.com/bestagentkits/agentkit\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "identity.reserved_publisher" + }, + { + "name": "kit provenance with members out of canonical order", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@agentkit/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"provenance\":{\"provider\":\"kit\",\"origin\":\"remote-signed\"},\"publisher\":{\"handle\":\"agentkit\",\"verifiedBy\":\"admin\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v2\",\"signature\":\"2lFLIZKg9EziM/aeisQwXUzCIzAkh4hrIkCfasqTaXz6Fun1TXSF3zQpgMJ5720+D4tiwb/KL4a4J0KaEXhsCw==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"kits/core/skills/ak-review-guardrails\",\"repo\":\"https://github.com/bestagentkits/agentkit\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "release.non_canonical" + } + ], + "invalidManifest": [ + { + "name": "rejects a v2 manifest without provenance", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "required" + }, + { + "name": "rejects a v1 manifest that carries provenance", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit", + "origin": "remote-signed" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unknown_field" + }, + { + "name": "rejects an unknown schema version", + "manifest": { + "schemaVersion": "skillx.release/v3", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit", + "origin": "remote-signed" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unsupported_value" + }, + { + "name": "rejects a provenance that is not an object", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": "kit", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "type" + }, + { + "name": "rejects a provider other than skillx or kit", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "git" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unsupported_value" + }, + { + "name": "rejects a kit provenance without origin", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "required" + }, + { + "name": "rejects an unknown kit origin", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit", + "origin": "registry-signed" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unsupported_value" + }, + { + "name": "rejects an origin on a skillx provenance", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "skillx", + "origin": "remote-signed" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unknown_field" + }, + { + "name": "rejects an unknown provenance member", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit", + "origin": "remote-signed", + "verified": true + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unknown_field" + }, + { + "name": "rejects a kit release outside the @agentkit publisher", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@bestagentkits/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "bestagentkits", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit", + "origin": "remote-signed" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "identity.invalid" + }, + { + "name": "rejects the @agentkit publisher with provider skillx", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "skillx" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "identity.reserved_publisher" + }, + { + "name": "rejects an ak- slug with provider skillx", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@solo.dev/ak-plan", + "version": "0.1.0-beta.1", + "channel": "beta", + "kind": "skill", + "releaseDigest": "sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544", + "artifactDigest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "artifactSize": 1536, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d", + "size": 44 + } + ], + "fileCount": 1, + "treeSize": 44, + "publisher": { + "handle": "solo.dev", + "verifiedBy": "none" + }, + "sourceProvenance": null, + "declaredScripts": [], + "createdAt": "2026-10-13T05:00:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "skillx" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "identity.reserved_alias" + }, + { + "name": "rejects a kit publisher handle that differs from the identity", + "manifest": { + "schemaVersion": "skillx.release/v2", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "bestagentkits", + "verifiedBy": "admin" + }, + "sourceProvenance": { + "repo": "https://github.com/bestagentkits/agentkit", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "kits/core/skills/ak-review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "provenance": { + "provider": "kit", + "origin": "remote-signed" + }, + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "mismatch" + } + ] +} diff --git a/packages/contracts/src/fixtures/release-v2-fixtures.ts b/packages/contracts/src/fixtures/release-v2-fixtures.ts new file mode 100644 index 0000000..1d192e4 --- /dev/null +++ b/packages/contracts/src/fixtures/release-v2-fixtures.ts @@ -0,0 +1,126 @@ +/** + * `skillx.release/v2` manifest fixtures: v1 members plus `provenance`. + * + * They reuse the v1 file trees (`RELEASE_FIXTURE_CONTENTS`), so the per-file + * hashes and `releaseDigest` match the v1 fixtures and only the identity, + * publisher and provenance differ. The signed cross-language vectors in + * `release-manifest-v2.json` are built from these manifests. + */ + +import type { SkillxReleaseManifest, UnsignedReleaseManifestV2 } from "../release-manifest"; +import { + RELEASE_FIXTURE_PLACEHOLDER_SIGNATURE, + RELEASE_MANIFEST_FIXTURE, + RELEASE_MANIFEST_MINIMAL_FIXTURE, +} from "./release-fixtures"; +import type { FixtureCase, MutableFixture } from "./types"; + +/** An AgentKit foundation skill released from a signed kit: `@agentkit/*` with provider `kit`. */ +export const RELEASE_MANIFEST_V2_KIT_FIXTURE: UnsignedReleaseManifestV2 = { + ...RELEASE_MANIFEST_FIXTURE, + schemaVersion: "skillx.release/v2", + identity: "@agentkit/review-guardrails", + publisher: { handle: "agentkit", verifiedBy: "admin" }, + sourceProvenance: { + repo: "https://github.com/bestagentkits/agentkit", + commit: "0123456789abcdef0123456789abcdef01234567", + path: "kits/core/skills/ak-review-guardrails", + }, + provenance: { provider: "kit", origin: "remote-signed" }, +}; + +/** A kit release packed from a development build of the kit. */ +export const RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE: UnsignedReleaseManifestV2 = { + ...RELEASE_MANIFEST_MINIMAL_FIXTURE, + schemaVersion: "skillx.release/v2", + identity: "@agentkit/tiny_skill", + publisher: { handle: "agentkit", verifiedBy: "admin" }, + provenance: { provider: "kit", origin: "local-dev" }, +}; + +/** An ordinary SkillX release written as v2: provider `skillx`, no origin. */ +export const RELEASE_MANIFEST_V2_SKILLX_FIXTURE: UnsignedReleaseManifestV2 = { + ...RELEASE_MANIFEST_MINIMAL_FIXTURE, + schemaVersion: "skillx.release/v2", + provenance: { provider: "skillx" }, +}; + +type Draft = MutableFixture & { + publisher: Record; + provenance?: unknown; +}; + +/** The draft's provenance as a mutable record; every base fixture carries one. */ +const provenanceOf = (draft: Draft) => draft.provenance as Record; + +/** A deep, mutable copy of a v2 fixture with a placeholder signature. */ +export function signedReleaseV2Draft(base: UnsignedReleaseManifestV2 = RELEASE_MANIFEST_V2_KIT_FIXTURE): MutableFixture { + const copy = JSON.parse(JSON.stringify(base)) as SkillxReleaseManifest; + copy.signature = RELEASE_FIXTURE_PLACEHOLDER_SIGNATURE; + return copy as unknown as MutableFixture; +} + +function rejected(name: string, expectedIssueCode: string, mutate: (draft: Draft) => void, base?: UnsignedReleaseManifestV2): FixtureCase { + const draft = signedReleaseV2Draft(base) as Draft; + mutate(draft); + return { name, group: "release-manifest", value: draft, expectedValid: false, expectedIssueCode }; +} + +const valid = (name: string, base: UnsignedReleaseManifestV2): FixtureCase => ({ + name, + group: "release-manifest", + value: signedReleaseV2Draft(base), + expectedValid: true, +}); + +/** Structure-only cases for `validateReleaseManifest` on v2; signatures are placeholders. */ +export const RELEASE_MANIFEST_V2_FIXTURES: FixtureCase[] = [ + valid("valid v2 remote-signed kit release", RELEASE_MANIFEST_V2_KIT_FIXTURE), + valid("valid v2 local-dev kit release", RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE), + valid("valid v2 skillx release", RELEASE_MANIFEST_V2_SKILLX_FIXTURE), + rejected("rejects a v2 manifest without provenance", "required", (d) => { + delete d.provenance; + }), + rejected("rejects a v1 manifest that carries provenance", "unknown_field", (d) => { + d.schemaVersion = "skillx.release/v1"; + }), + rejected("rejects an unknown schema version", "unsupported_value", (d) => { + d.schemaVersion = "skillx.release/v3"; + }), + rejected("rejects a provenance that is not an object", "type", (d) => { + d.provenance = "kit"; + }), + rejected("rejects a provider other than skillx or kit", "unsupported_value", (d) => { + d.provenance = { provider: "git" }; + }), + rejected("rejects a kit provenance without origin", "required", (d) => { + delete provenanceOf(d).origin; + }), + rejected("rejects an unknown kit origin", "unsupported_value", (d) => { + provenanceOf(d).origin = "registry-signed"; + }), + rejected("rejects an origin on a skillx provenance", "unknown_field", (d) => { + d.provenance = { provider: "skillx", origin: "remote-signed" }; + }), + rejected("rejects an unknown provenance member", "unknown_field", (d) => { + provenanceOf(d).verified = true; + }), + rejected("rejects a kit release outside the @agentkit publisher", "identity.invalid", (d) => { + d.identity = "@bestagentkits/review-guardrails"; + d.publisher.handle = "bestagentkits"; + }), + rejected("rejects the @agentkit publisher with provider skillx", "identity.reserved_publisher", (d) => { + d.provenance = { provider: "skillx" }; + }), + rejected( + "rejects an ak- slug with provider skillx", + "identity.reserved_alias", + (d) => { + d.identity = "@solo.dev/ak-plan"; + }, + RELEASE_MANIFEST_V2_SKILLX_FIXTURE, + ), + rejected("rejects a kit publisher handle that differs from the identity", "mismatch", (d) => { + d.publisher.handle = "bestagentkits"; + }), +]; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f854a73..c1b9a64 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -12,6 +12,7 @@ export * from "./compatibility"; export * from "./package-manifest"; export * from "./canonical-json"; export * from "./release-manifest"; +export * from "./release-provenance"; export * from "./release-status"; export * from "./strict-json"; export * from "./exec-capabilities"; diff --git a/packages/contracts/src/package-manifest.test.ts b/packages/contracts/src/package-manifest.test.ts index d2a4906..713ba8a 100644 --- a/packages/contracts/src/package-manifest.test.ts +++ b/packages/contracts/src/package-manifest.test.ts @@ -9,6 +9,7 @@ import { VERIFICATION_EVIDENCE_FIXTURES, } from "./fixtures"; import { RELEASE_MANIFEST_FIXTURES } from "./fixtures/release-fixtures"; +import { RELEASE_MANIFEST_V2_FIXTURES } from "./fixtures/release-v2-fixtures"; import { RELEASE_STATUS_FIXTURES } from "./fixtures/release-status-fixtures"; import * as contracts from "./index"; import { validateReleaseManifest } from "./release-manifest"; @@ -38,7 +39,7 @@ const VALIDATOR_BY_GROUP: Record { valid: boolean; i }; /** Every fixture case, including the test-only signed-release fixtures the root export leaves out. */ -const EVERY_FIXTURE = [...ALL_FIXTURES, ...RELEASE_MANIFEST_FIXTURES, ...RELEASE_STATUS_FIXTURES]; +const EVERY_FIXTURE = [...ALL_FIXTURES, ...RELEASE_MANIFEST_FIXTURES, ...RELEASE_MANIFEST_V2_FIXTURES, ...RELEASE_STATUS_FIXTURES]; describe("skillx.package/v1 fixtures", () => { it("covers both a valid and a rejected case for every contract group", () => { @@ -53,6 +54,7 @@ describe("skillx.package/v1 fixtures", () => { it("keeps the test-only signed-release fixtures out of the package root", () => { expect("RELEASE_MANIFEST_FIXTURES" in contracts).toBe(false); + expect("RELEASE_MANIFEST_V2_FIXTURES" in contracts).toBe(false); expect("RELEASE_STATUS_FIXTURES" in contracts).toBe(false); expect(ALL_FIXTURES.some((f) => f.group === "release-manifest" || f.group === "release-status")).toBe(false); }); diff --git a/packages/contracts/src/release-manifest.ts b/packages/contracts/src/release-manifest.ts index 59925b3..ea76592 100644 --- a/packages/contracts/src/release-manifest.ts +++ b/packages/contracts/src/release-manifest.ts @@ -1,5 +1,5 @@ /** - * `skillx.release/v1` signed release manifest contract. + * `skillx.release/v1` and `skillx.release/v2` signed release manifest contract. * * A release manifest pins one immutable package version to the canonical * `agentkit.skill-tree/v1` tree of its files. `releaseDigest` is the tree @@ -11,6 +11,10 @@ * member except `signature`. `releaseDigest` must equal the tree digest of * `files`; signing and verification both enforce it. * + * v2 is v1 plus the required `provenance` member (`release-provenance.ts`), + * which lets a kit release use the reserved `@agentkit` publisher. v1 stays + * unchanged: it has no `provenance`, and its provider is always `skillx`. + * * Validators and verifiers never throw, whatever the input. */ @@ -18,13 +22,17 @@ import { ERROR_CODES, SkillTreeError, checkCollisions, + checkDirectoryAlias, checkPublisher, compareUtf8, isNoisePath, serializeTree, + validateIdentity, validatePath, } from "@skillx/skill-tree/rules"; import { canonicalJsonBytes } from "./canonical-json"; +import { validateReleaseProvenance } from "./release-provenance"; +import type { ReleaseProvenance, ReleaseProvider } from "./release-provenance"; import { PACKAGE_NAME_PATTERN, RELEASE_CHANNELS, isPackageKind, PACKAGE_KINDS } from "./package-manifest"; import type { PackageKind, ReleaseChannel } from "./package-manifest"; import { @@ -56,6 +64,10 @@ export type { ReleasePublicKeys } from "./release-signature"; export { compareUtf8 } from "@skillx/skill-tree/rules"; export const SKILLX_RELEASE_SCHEMA_VERSION = "skillx.release/v1"; +export const SKILLX_RELEASE_SCHEMA_VERSION_V2 = "skillx.release/v2"; +/** Every manifest schema this contract signs and verifies. */ +export const SKILLX_RELEASE_SCHEMA_VERSIONS = [SKILLX_RELEASE_SCHEMA_VERSION, SKILLX_RELEASE_SCHEMA_VERSION_V2] as const; +export type SkillxReleaseSchemaVersion = (typeof SKILLX_RELEASE_SCHEMA_VERSIONS)[number]; /** Verification failure codes of the release manifest itself. */ export const RELEASE_MANIFEST_CODES = { @@ -123,8 +135,7 @@ export interface ReleaseSourceProvenance { path: string; } -export interface UnsignedReleaseManifest { - schemaVersion: typeof SKILLX_RELEASE_SCHEMA_VERSION; +interface ReleaseManifestMembers { identity: string; version: string; channel: ReleaseChannel; @@ -151,10 +162,22 @@ export interface UnsignedReleaseManifest { signatureAlgorithm: typeof RELEASE_SIGNATURE_ALGORITHM; } -export interface SkillxReleaseManifest extends UnsignedReleaseManifest { - signature: string; +/** A `skillx.release/v1` manifest: provider `skillx`, no `provenance` member. */ +export interface UnsignedReleaseManifestV1 extends ReleaseManifestMembers { + schemaVersion: typeof SKILLX_RELEASE_SCHEMA_VERSION; + provenance?: never; +} + +/** A `skillx.release/v2` manifest with explicit source provenance. */ +export interface UnsignedReleaseManifestV2 extends ReleaseManifestMembers { + schemaVersion: typeof SKILLX_RELEASE_SCHEMA_VERSION_V2; + provenance: ReleaseProvenance; } +export type UnsignedReleaseManifest = UnsignedReleaseManifestV1 | UnsignedReleaseManifestV2; + +export type SkillxReleaseManifest = UnsignedReleaseManifest & { signature: string }; + const MANIFEST_FIELDS = [ "schemaVersion", "identity", @@ -178,28 +201,38 @@ const FILE_FIELDS = ["path", "mode", "sha256", "size"] as const; const PUBLISHER_FIELDS = ["handle", "verifiedBy"] as const; const PROVENANCE_FIELDS = ["repo", "commit", "path"] as const; +const MANIFEST_FIELDS_V2 = [...MANIFEST_FIELDS, "provenance"] as const; + const isSize = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; function oneOf(values: readonly T[], value: unknown): value is T { return typeof value === "string" && (values as readonly string[]).includes(value); } -function checkFields(value: Record, allowed: readonly string[], path: string, issues: ValidationIssue[]): void { - checkSchemaFields(value, allowed, path, SKILLX_RELEASE_SCHEMA_VERSION, issues); +type FieldCheck = (value: Record, allowed: readonly string[], path: string) => void; + +function fieldCheck(schema: string, issues: ValidationIssue[]): FieldCheck { + return (value, allowed, path) => checkSchemaFields(value, allowed, path, schema, issues); } function treeIssue(path: string, error: SkillTreeError): ValidationIssue { return issue(path, error.code, error.message); } -function validateIdentityField(identity: unknown, issues: ValidationIssue[]): string | null { +/** + * `provider` is `skillx` for v1, the validated v2 provider, or null when the + * v2 provider is invalid (then only the identity shape is checked). Under v2 a + * `skillx` slug must not take the reserved `ak-` alias prefix either. + */ +function validateIdentityField(identity: unknown, provider: ReleaseProvider | null, v2: boolean, issues: ValidationIssue[]): string | null { if (typeof identity !== "string" || !PACKAGE_NAME_PATTERN.test(identity)) { issues.push(issue("identity", "pattern", "must be '@publisher/slug' using lowercase slug characters")); return null; } try { - // Every SkillX release comes from the `skillx` provider, so `@agentkit/*` is always reserved. - checkPublisher(identity, "skillx"); + if (provider === null) validateIdentity(identity); + else checkPublisher(identity, provider); + if (v2 && provider === "skillx") checkDirectoryAlias(identity, identity.slice(identity.indexOf("/") + 1)); } catch (error) { if (!(error instanceof SkillTreeError)) throw error; issues.push(treeIssue("identity", error)); @@ -208,7 +241,7 @@ function validateIdentityField(identity: unknown, issues: ValidationIssue[]): st return identity.slice(1, identity.indexOf("/")); } -function validateFiles(files: unknown, issues: ValidationIssue[]): ReleaseFile[] | null { +function validateFiles(files: unknown, checkFields: FieldCheck, issues: ValidationIssue[]): ReleaseFile[] | null { if (!Array.isArray(files)) { issues.push(issue("files", "type", "must be an array")); return null; @@ -226,7 +259,7 @@ function validateFiles(files: unknown, issues: ValidationIssue[]): ReleaseFile[] issues.push(issue(at, "type", "must be an object")); return; } - checkFields(file, FILE_FIELDS, at, issues); + checkFields(file, FILE_FIELDS, at); const { path, mode, sha256, size } = file; if (typeof path !== "string") { issues.push(issue(`${at}.path`, "type", "must be a string")); @@ -262,12 +295,12 @@ function validateFiles(files: unknown, issues: ValidationIssue[]): ReleaseFile[] return valid; } -function validatePublisher(publisher: unknown, handle: string | null, issues: ValidationIssue[]): void { +function validatePublisher(publisher: unknown, handle: string | null, checkFields: FieldCheck, issues: ValidationIssue[]): void { if (!isPlainObject(publisher)) { issues.push(issue("publisher", "type", "must be an object")); return; } - checkFields(publisher, PUBLISHER_FIELDS, "publisher", issues); + checkFields(publisher, PUBLISHER_FIELDS, "publisher"); if (typeof publisher.handle !== "string") { issues.push(issue("publisher.handle", "type", "must be a string")); } else if (handle !== null && publisher.handle !== handle) { @@ -288,13 +321,13 @@ function isHttpsRepo(value: unknown): boolean { } } -function validateProvenance(provenance: unknown, issues: ValidationIssue[]): void { +function validateSourceProvenance(provenance: unknown, checkFields: FieldCheck, issues: ValidationIssue[]): void { if (provenance === null) return; if (!isPlainObject(provenance)) { issues.push(issue("sourceProvenance", "type", "must be an object or null")); return; } - checkFields(provenance, PROVENANCE_FIELDS, "sourceProvenance", issues); + checkFields(provenance, PROVENANCE_FIELDS, "sourceProvenance"); if (!isHttpsRepo(provenance.repo)) { issues.push(issue("sourceProvenance.repo", "format", `must be an https URL without credentials, at most ${MAX_SOURCE_REPO_LENGTH} characters`)); } @@ -334,12 +367,18 @@ function collectIssues(input: unknown, requireSignature: boolean): ValidationIss if (!isPlainObject(input)) return [issue("", "type", "release manifest must be an object")]; const issues: ValidationIssue[] = []; if (!checkCanonicalValue(input, issues)) return issues; - checkFields(input, requireSignature ? [...MANIFEST_FIELDS, "signature"] : MANIFEST_FIELDS, "", issues); - - if (input.schemaVersion !== SKILLX_RELEASE_SCHEMA_VERSION) { - issues.push(issue("schemaVersion", "unsupported_value", `must equal '${SKILLX_RELEASE_SCHEMA_VERSION}'`)); + const v2 = input.schemaVersion === SKILLX_RELEASE_SCHEMA_VERSION_V2; + const schema = v2 ? SKILLX_RELEASE_SCHEMA_VERSION_V2 : SKILLX_RELEASE_SCHEMA_VERSION; + const checkFields = fieldCheck(schema, issues); + const fields = v2 ? MANIFEST_FIELDS_V2 : MANIFEST_FIELDS; + checkFields(input, requireSignature ? [...fields, "signature"] : fields, ""); + + if (!v2 && input.schemaVersion !== SKILLX_RELEASE_SCHEMA_VERSION) { + issues.push(issue("schemaVersion", "unsupported_value", `must be one of: ${SKILLX_RELEASE_SCHEMA_VERSIONS.join(", ")}`)); } - const handle = validateIdentityField(input.identity, issues); + // v1 is always provider `skillx`; a missing v2 `provenance` is reported by checkFields. + const provider = !v2 ? "skillx" : "provenance" in input ? validateReleaseProvenance(input.provenance, issues, checkFields) : null; + const handle = validateIdentityField(input.identity, provider, v2, issues); if ( typeof input.version !== "string" || input.version.length > MAX_RELEASE_VERSION_LENGTH || @@ -369,7 +408,7 @@ function collectIssues(input: unknown, requireSignature: boolean): ValidationIss issues.push(issue("artifactSize", "type", "must be a positive safe integer")); } - const files = validateFiles(input.files, issues); + const files = validateFiles(input.files, checkFields, issues); if (!isSize(input.fileCount)) { issues.push(issue("fileCount", "type", "must be a non-negative safe integer")); } else if (files !== null && input.fileCount !== files.length) { @@ -383,8 +422,8 @@ function collectIssues(input: unknown, requireSignature: boolean): ValidationIss issues.push(issue("treeSize", "mismatch", "must equal the sum of file sizes")); } - validatePublisher(input.publisher, handle, issues); - validateProvenance(input.sourceProvenance, issues); + validatePublisher(input.publisher, handle, checkFields, issues); + validateSourceProvenance(input.sourceProvenance, checkFields, issues); validateDeclaredScripts(input.declaredScripts, files, issues); if (!isReleaseTimestamp(input.createdAt)) { issues.push(issue("createdAt", "format", "must be an RFC 3339 UTC timestamp with whole seconds, e.g. 2026-10-13T04:30:00Z")); @@ -393,7 +432,7 @@ function collectIssues(input: unknown, requireSignature: boolean): ValidationIss return issues; } -/** Validates a signed `skillx.release/v1` manifest (structure only, not the signature). */ +/** Validates a signed `skillx.release/v1` or `skillx.release/v2` manifest (structure only, not the signature). */ export function validateReleaseManifest(input: unknown): ValidationResult { const issues = collectIssues(input, true); return issues.length === 0 ? validationOk() : validationFail(issues); diff --git a/packages/contracts/src/release-provenance.test.ts b/packages/contracts/src/release-provenance.test.ts new file mode 100644 index 0000000..5ac02c1 --- /dev/null +++ b/packages/contracts/src/release-provenance.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { RELEASE_MANIFEST_FIXTURE } from "./fixtures/release-fixtures"; +import { + RELEASE_MANIFEST_V2_KIT_FIXTURE, + RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE, + RELEASE_MANIFEST_V2_SKILLX_FIXTURE, +} from "./fixtures/release-v2-fixtures"; +import { releaseProvenanceOf, releaseTrust } from "./release-provenance"; + +describe("releaseTrust", () => { + const kit = RELEASE_MANIFEST_V2_KIT_FIXTURE; + + it("grants signed-kit only for a remote-signed kit release whose own tree had its kit signature verified", () => { + expect(releaseTrust(kit)).toBe("local-unverified"); + expect(releaseTrust(kit, { kitVerifiedReleaseDigest: kit.releaseDigest })).toBe("signed-kit"); + expect(releaseTrust(kit, { kitVerifiedReleaseDigest: RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE.releaseDigest })).toBe("local-unverified"); + }); + + it("never grants signed-kit to a local-dev kit, a skillx v2 or a v1 release", () => { + for (const manifest of [RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE, RELEASE_MANIFEST_V2_SKILLX_FIXTURE, RELEASE_MANIFEST_FIXTURE]) { + expect(releaseTrust(manifest, { kitVerifiedReleaseDigest: manifest.releaseDigest })).toBe("local-unverified"); + } + }); + + it("reads a v1 manifest as provider skillx", () => { + expect(releaseProvenanceOf(RELEASE_MANIFEST_FIXTURE)).toEqual({ provider: "skillx" }); + expect(releaseProvenanceOf(kit)).toEqual({ provider: "kit", origin: "remote-signed" }); + }); +}); diff --git a/packages/contracts/src/release-provenance.ts b/packages/contracts/src/release-provenance.ts new file mode 100644 index 0000000..d64042d --- /dev/null +++ b/packages/contracts/src/release-provenance.ts @@ -0,0 +1,95 @@ +/** + * Kit provenance of `skillx.release/v2` manifests. + * + * `skillx.release/v1` has no provider member: every v1 release is provider + * `skillx`, so `@agentkit/*` is always reserved there. v2 adds the required, + * closed `provenance` member: `{"provider": "skillx"}` or `{"provider": + * "kit", "origin": "remote-signed" | "local-dev"}`. The identity must pair + * with the provider (`@agentkit/*` only with `kit`, every other publisher only + * with `skillx`), and a `skillx` slug must not use the `ak-` alias prefix. + * + * `origin` records where the kit content came from; it is a claim the + * registry countersigns, not a kit signature. A SkillX signature never makes + * a kit release `signed-kit`: `releaseTrust` grants that only when the caller + * verified the AgentKit kit signature of that exact tree itself. + */ + +import { KIT_ORIGINS, trustOf } from "@skillx/skill-tree/rules"; +import type { KitOrigin, Trust } from "@skillx/skill-tree/rules"; +import { isPlainObject, issue } from "./validation"; +import type { ValidationIssue } from "./validation"; + +export { KIT_ORIGINS } from "@skillx/skill-tree/rules"; +export type { KitOrigin } from "@skillx/skill-tree/rules"; + +/** Source providers a `skillx.release/v2` manifest may name. */ +export const RELEASE_PROVIDERS = ["skillx", "kit"] as const; +export type ReleaseProvider = (typeof RELEASE_PROVIDERS)[number]; + +export type ReleaseProvenance = { provider: "skillx" } | { provider: "kit"; origin: KitOrigin }; + +function oneOf(values: readonly T[], value: unknown): value is T { + return typeof value === "string" && (values as readonly string[]).includes(value); +} + +/** + * Validates the v2 `provenance` member and returns its provider, or null + * when the provider itself is invalid (the identity pairing is then not + * checked). `checkFields` reports unknown and missing members with the + * manifest schema in the message. + */ +export function validateReleaseProvenance( + value: unknown, + issues: ValidationIssue[], + checkFields: (value: Record, allowed: readonly string[], path: string) => void, +): ReleaseProvider | null { + if (!isPlainObject(value)) { + issues.push(issue("provenance", "type", "must be an object")); + return null; + } + if (!oneOf(RELEASE_PROVIDERS, value.provider)) { + checkFields(value, ["provider"], "provenance"); + issues.push(issue("provenance.provider", "unsupported_value", `must be one of: ${RELEASE_PROVIDERS.join(", ")}`)); + return null; + } + if (value.provider === "skillx") { + checkFields(value, ["provider"], "provenance"); + return "skillx"; + } + checkFields(value, ["provider", "origin"], "provenance"); + if ("origin" in value && !oneOf(KIT_ORIGINS, value.origin)) { + issues.push(issue("provenance.origin", "unsupported_value", `must be one of: ${KIT_ORIGINS.join(", ")}`)); + } + return "kit"; +} + +/** The provenance of a valid manifest; a v1 manifest is always provider `skillx`. */ +export function releaseProvenanceOf(manifest: { provenance?: ReleaseProvenance }): ReleaseProvenance { + return manifest.provenance ?? { provider: "skillx" }; +} + +export interface ReleaseTrustOptions { + /** + * The `releaseDigest` of the tree whose AgentKit kit signature the caller + * verified itself. The SkillX registry signature never counts. + */ + kitVerifiedReleaseDigest?: string; +} + +/** + * Lifecycle trust of a verified, structurally valid release manifest (it + * throws `SkillTreeError` on an identity the manifest rules reject). A + * `skillx` release is always `local-unverified`: a registry signature is not + * a publisher signature. A `kit` release is `signed-kit` only when its origin + * is `remote-signed` and `kitVerifiedReleaseDigest` equals its + * `releaseDigest`; otherwise it is `local-unverified`, as for a `local-dev` kit. + */ +export function releaseTrust( + manifest: { identity: string; releaseDigest: string; provenance?: ReleaseProvenance }, + options: ReleaseTrustOptions = {}, +): Trust { + const provenance = releaseProvenanceOf(manifest); + if (provenance.provider === "skillx") return trustOf(manifest.identity, { provider: "skillx", verified: true }); + const signed = provenance.origin === "remote-signed" && options.kitVerifiedReleaseDigest === manifest.releaseDigest; + return trustOf(manifest.identity, { provider: "kit", origin: signed ? "remote-signed" : "local-dev" }); +} diff --git a/packages/contracts/src/release-signature.ts b/packages/contracts/src/release-signature.ts index 30c4c71..b880041 100644 --- a/packages/contracts/src/release-signature.ts +++ b/packages/contracts/src/release-signature.ts @@ -1,6 +1,7 @@ /** * Signature, key and timestamp rules shared by the signed SkillX release - * documents (`skillx.release/v1` and `skillx.release-status/v1`). + * documents (`skillx.release/v1`, `skillx.release/v2` and + * `skillx.release-status/v1`). * * A document is signed with Ed25519 over the UTF-8 canonical JSON (see * `canonical-json.ts`) of every member except `signature`, so `keyId` and @@ -11,7 +12,7 @@ * Domain separation: there is no prefix before the payload. Every signed * payload holds its own `schemaVersion`, every verifier requires its one * schema version and rejects unknown members, and registry release keys sign - * nothing but these two schemas, so a signature over one document type never + * nothing but these schemas, so a signature over one document type never * verifies as the other. * * Served documents are exactly `canonicalJson(document)`, signature included; diff --git a/packages/contracts/src/release-vectors-v2.test.ts b/packages/contracts/src/release-vectors-v2.test.ts new file mode 100644 index 0000000..7999e8e --- /dev/null +++ b/packages/contracts/src/release-vectors-v2.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { canonicalJson } from "./canonical-json"; +import { RELEASE_FIXTURE_CONTENTS, RELEASE_FIXTURE_KEY_ID, RELEASE_MANIFEST_FIXTURE } from "./fixtures/release-fixtures"; +import { + RELEASE_MANIFEST_V2_FIXTURES, + RELEASE_MANIFEST_V2_KIT_FIXTURE, + RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE, + RELEASE_MANIFEST_V2_SKILLX_FIXTURE, +} from "./fixtures/release-v2-fixtures"; +import vectorFile from "./fixtures/release-manifest-v2.json"; +import { + RELEASE_SIGNATURE_CODES, + releaseSigningPayload, + signReleaseManifest, + validateReleaseManifest, + verifyReleaseManifest, +} from "./release-manifest"; +import type { SkillxReleaseManifest, UnsignedReleaseManifest } from "./release-manifest"; +import { releaseTrust } from "./release-provenance"; +import { signPayload } from "./release-signature"; +import { serializeTree } from "@skillx/skill-tree/rules"; +import { + VECTOR_KEY_SEED_TEXT, + base64, + clone, + decoder, + describeVectorKey, + encoder, + fromBase64, + hex, + sha256, + vectorKey, + writeVectorFile, + writeVectorsRequested, +} from "./test-support/vector-key"; +import type { VectorKey, VectorKeyDescription } from "./test-support/vector-key"; + +/** + * Golden cross-language vectors for `skillx.release/v2` (kit provenance), + * exported for AgentKit (`fixtures/release-manifest-v2.json`). Same test key, + * canonicalization and verification order as the v1 vectors; v2 adds the + * required `provenance` member and the identity/provider pairing. + * `expectedTrust` is the trust a verifier that has not verified an AgentKit + * kit signature must assign: a registry signature never makes a release + * `signed-kit`. + * + * Regenerate with `SKILLX_WRITE_RELEASE_VECTORS=1 pnpm vitest run packages/contracts/src`. + */ + +interface ReleaseVectorsV2 { + description: string; + verifierRules: string[]; + testKey: VectorKeyDescription; + valid: Array<{ + name: string; + document: string; + manifest: SkillxReleaseManifest; + signingPayload: string; + treeSerialization: string; + fileContentsBase64: Record; + expectedTrust: string; + }>; + rejected: Array<{ name: string; document: string; expectedCode: string }>; + invalidManifest: Array<{ name: string; manifest: unknown; expectedCode: string }>; +} + +/** Signs any manifest payload, including one `signReleaseManifest` refuses. */ +async function signRaw(manifest: Record, key: VectorKey): Promise { + const signature = await signPayload(releaseSigningPayload(manifest as unknown as UnsignedReleaseManifest), key.privateKey); + return { ...(manifest as unknown as UnsignedReleaseManifest), signature }; +} + +const contentsBase64 = (contents: Readonly>): Record => + Object.fromEntries(Object.entries(contents).map(([path, text]) => [path, base64(encoder.encode(text))])); + +async function buildVectors(): Promise { + const key = await vectorKey(VECTOR_KEY_SEED_TEXT); + const valid: ReleaseVectorsV2["valid"] = []; + for (const [name, base, contents] of [ + ["remote-signed kit release of an @agentkit foundation", RELEASE_MANIFEST_V2_KIT_FIXTURE, RELEASE_FIXTURE_CONTENTS.full], + ["local-dev kit release", RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE, RELEASE_FIXTURE_CONTENTS.minimal], + ["skillx release written as v2", RELEASE_MANIFEST_V2_SKILLX_FIXTURE, RELEASE_FIXTURE_CONTENTS.minimal], + ] as const) { + const manifest = await signReleaseManifest(base, key.privateKey); + valid.push({ + name, + document: canonicalJson(manifest), + manifest, + signingPayload: decoder.decode(releaseSigningPayload(manifest)), + treeSerialization: serializeTree(manifest.files), + fileContentsBase64: contentsBase64(contents), + expectedTrust: releaseTrust(manifest), + }); + } + + const signedKit = valid[0]!.manifest; + const signedLocal = valid[1]!.manifest; + const tamper = (base: SkillxReleaseManifest, mutate: (m: Record) => void): string => { + const copy = clone(base) as unknown as Record; + mutate(copy); + return canonicalJson(copy); + }; + const mismatch = RELEASE_SIGNATURE_CODES.mismatch; + const { provenance: _dropped, ...kitMembers } = clone(RELEASE_MANIFEST_V2_KIT_FIXTURE); + const signedV1Agentkit = await signRaw({ ...kitMembers, schemaVersion: "skillx.release/v1" }, key); + const signedV1 = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, key.privateKey); + + return { + description: + "skillx.release/v2 signed manifest vectors (kit provenance). The test key is derived from a public seed text and MUST NOT be trusted outside tests.", + verifierRules: [ + "canonicalization, tree digest and signature rules are those of skillx.release/v1 (release-manifest-v1.json)", + "schemaVersion selects the closed member set: v1 has no provenance member (unknown_field), v2 requires it (required); any other schemaVersion is unsupported_value", + "provenance is {provider: 'skillx'} or {provider: 'kit', origin: 'remote-signed' | 'local-dev'}; other providers or origins are unsupported_value, extra members unknown_field", + "@agentkit/* requires provider kit (identity.reserved_publisher); provider kit requires @agentkit/* (identity.invalid); a provider skillx slug must not start with ak- (identity.reserved_alias)", + "trust is local-unverified unless the verifier itself verified the AgentKit kit signature of a remote-signed kit release; the registry signature alone never yields signed-kit", + ], + testKey: describeVectorKey(RELEASE_FIXTURE_KEY_ID, VECTOR_KEY_SEED_TEXT, key), + valid, + rejected: [ + { + name: "origin upgraded from local-dev to remote-signed after signing", + document: tamper(signedLocal, (m) => void (m.provenance = { provider: "kit", origin: "remote-signed" })), + expectedCode: mismatch, + }, + { + name: "provenance changed from kit to skillx with a non-agentkit identity after signing", + document: tamper(signedKit, (m) => { + m.identity = "@bestagentkits/review-guardrails"; + m.publisher = { handle: "bestagentkits", verifiedBy: "admin" }; + m.provenance = { provider: "skillx" }; + }), + expectedCode: mismatch, + }, + { + name: "v1 manifest relabelled v2 with provenance after signing", + document: tamper(signedV1, (m) => { + m.schemaVersion = "skillx.release/v2"; + m.provenance = { provider: "skillx" }; + }), + expectedCode: mismatch, + }, + { + name: "@agentkit release signed as v1 without provenance", + document: canonicalJson(signedV1Agentkit), + expectedCode: "identity.reserved_publisher", + }, + { + name: "kit provenance with members out of canonical order", + document: canonicalJson(signedKit).replace('{"origin":"remote-signed","provider":"kit"}', '{"provider":"kit","origin":"remote-signed"}'), + expectedCode: "release.non_canonical", + }, + ], + invalidManifest: RELEASE_MANIFEST_V2_FIXTURES.filter((c) => !c.expectedValid).map((c) => ({ + name: c.name, + manifest: c.value, + expectedCode: c.expectedIssueCode ?? "", + })), + }; +} + +const committed = vectorFile as unknown as ReleaseVectorsV2; +const trustedKeys = () => new Map([[committed.testKey.keyId, fromBase64(committed.testKey.publicKeyBase64)]]); + +describe("skillx.release/v2 cross-language vectors", () => { + it.runIf(writeVectorsRequested)("writes the vector file", async () => { + await writeVectorFile("release-manifest-v2.json", await buildVectors()); + }); + + it.skipIf(writeVectorsRequested)("match the committed vector file", async () => { + expect(committed).toEqual(await buildVectors()); + }); + + it.skipIf(writeVectorsRequested)("verify every valid vector and never grant signed-kit from the registry signature", async () => { + expect(committed.valid.length).toBe(3); + for (const vector of committed.valid) { + expect(await verifyReleaseManifest(vector.document, trustedKeys()), vector.name).toEqual({ valid: true, issues: [] }); + expect(vector.manifest.schemaVersion).toBe("skillx.release/v2"); + expect(vector.expectedTrust, vector.name).toBe("local-unverified"); + expect(`sha256:${hex(await sha256(encoder.encode(vector.treeSerialization)))}`).toBe(vector.manifest.releaseDigest); + } + }); + + it.skipIf(writeVectorsRequested)("reject every rejected document with exactly its code", async () => { + for (const vector of committed.rejected) { + const result = await verifyReleaseManifest(vector.document, trustedKeys()); + expect(result.issues.map((i) => i.code), vector.name).toEqual([vector.expectedCode]); + } + }); + + it.skipIf(writeVectorsRequested)("reject every invalid-manifest vector with its code", () => { + expect(committed.invalidManifest.length).toBeGreaterThan(10); + for (const vector of committed.invalidManifest) { + const result = validateReleaseManifest(vector.manifest); + expect(result.valid, vector.name).toBe(false); + expect(result.issues.map((i) => i.code), vector.name).toContain(vector.expectedCode); + } + }); +}); diff --git a/packages/skill-tree/src/index.ts b/packages/skill-tree/src/index.ts index da3b015..ea7a053 100644 --- a/packages/skill-tree/src/index.ts +++ b/packages/skill-tree/src/index.ts @@ -35,6 +35,7 @@ export type { RandomAccessSource } from "./zip-records"; export { IDENTITY_PATTERN, + KIT_ORIGINS, RESERVED_PUBLISHER, checkAlias, checkDirectoryAlias, diff --git a/packages/skill-tree/src/rules.ts b/packages/skill-tree/src/rules.ts index 4c6748c..08621bd 100644 --- a/packages/skill-tree/src/rules.ts +++ b/packages/skill-tree/src/rules.ts @@ -14,14 +14,17 @@ export { UNICODE_VERSION, isFormat } from "./unicode-15"; export { IDENTITY_PATTERN, + KIT_ORIGINS, RESERVED_PUBLISHER, checkAlias, checkDirectoryAlias, checkPublisher, directoryAliasKey, kitIdentity, + trustOf, validateIdentity, } from "./identity"; +export type { KitOrigin, Provenance, Provider, Trust } from "./identity"; export { frontmatterExposure, skillFileExposure } from "./exposure"; export type { Exposure, ExposureResult } from "./exposure";