From fb20884f951879d188bdf236e88f4379e2d4005b Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 19 Oct 2026 13:24:33 +0000
Subject: [PATCH 1/3] feat(releases): add skillx.release/v2 with kit provenance
for @agentkit releases
Kit releases carry a closed provenance member ({provider: kit, origin})
and are signed as skillx.release/v2; every other release stays v1, so
existing v1 verifiers keep working. @agentkit/* is accepted only with
provider kit, under a releases:publish:@agentkit key of the active
agentkit publisher. The registry signature never yields signed-kit
trust: releaseTrust grants it only after a verified kit signature.
Adds v2 golden vectors, ingest and CLI (--kit-origin) support.
Refs #100
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Nti6zFrfo6zR7enh3JrDQy
---
apps/web/app/lib/releases/ingest-release.ts | 22 +-
apps/web/app/lib/releases/release-request.ts | 66 +-
apps/web/app/lib/releases/sign-manifest.ts | 30 +-
.../releases/release-kit-publish.test.ts | 110 +++
apps/web/tests/releases/sign-manifest.test.ts | 25 +-
.../release-publish-provenance.test.ts | 19 +
packages/cli/src/commands/release-publish.ts | 20 +
.../src/fixtures/release-manifest-v2.json | 899 ++++++++++++++++++
.../src/fixtures/release-v2-fixtures.ts | 126 +++
packages/contracts/src/index.ts | 1 +
.../contracts/src/package-manifest.test.ts | 4 +-
packages/contracts/src/release-manifest.ts | 89 +-
packages/contracts/src/release-provenance.ts | 94 ++
packages/contracts/src/release-signature.ts | 5 +-
.../contracts/src/release-vectors-v2.test.ts | 215 +++++
packages/skill-tree/src/index.ts | 1 +
packages/skill-tree/src/rules.ts | 3 +
17 files changed, 1676 insertions(+), 53 deletions(-)
create mode 100644 apps/web/tests/releases/release-kit-publish.test.ts
create mode 100644 packages/cli/src/commands/release-publish-provenance.test.ts
create mode 100644 packages/contracts/src/fixtures/release-manifest-v2.json
create mode 100644 packages/contracts/src/fixtures/release-v2-fixtures.ts
create mode 100644 packages/contracts/src/release-provenance.ts
create mode 100644 packages/contracts/src/release-vectors-v2.test.ts
diff --git a/apps/web/app/lib/releases/ingest-release.ts b/apps/web/app/lib/releases/ingest-release.ts
index a3f2b4a..479ce94 100644
--- a/apps/web/app/lib/releases/ingest-release.ts
+++ b/apps/web/app/lib/releases/ingest-release.ts
@@ -2,25 +2,31 @@
* 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 provider the metadata asks for) -> 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.
+ *
+ * Kit releases: metadata `provenance: {provider: "kit", origin}` is the only
+ * way to publish `@agentkit/*`. It still needs a key scoped
+ * `releases:publish:@agentkit` whose owner owns the active, admin-provisioned
+ * `agentkit` publisher, and it is signed as `skillx.release/v2`. Every other
+ * release is signed as `skillx.release/v1`.
*/
-import { ReleaseManifestError, SKILLX_RELEASE_SCHEMA_VERSION } from "@skillx/contracts";
+import { ReleaseManifestError } from "@skillx/contracts";
import { ERROR_CODES, SkillTreeError, readTarTree } from "@skillx/skill-tree";
import type { TreeManifest } from "@skillx/skill-tree";
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 { RELEASE_METADATA_HEADER, parseReleaseMetadata, parseReleaseName, readUploadBody, requestedProvider } from "./release-request";
import { scanRelease } from "./release-scan";
import { findPackage, findRelease, insertRelease } from "./release-store";
import type { ExistingRelease } from "./release-store";
import { artifactKey, blobKey, forEachLimited, manifestKey, putCreateOnly } from "./r2-create-only";
-import { signRelease } from "./sign-manifest";
+import { manifestSchemaVersionOf, signRelease } from "./sign-manifest";
import type { RegistrySigner } from "./sign-manifest";
export interface IngestDeps {
@@ -80,10 +86,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`);
@@ -119,6 +126,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 +162,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: manifestSchemaVersionOf(metadata),
artifactKey: artifactKey(artifactHex),
artifactDigest: `sha256:${artifactHex}`,
artifactSize: body.length,
diff --git a/apps/web/app/lib/releases/release-request.ts b/apps/web/app/lib/releases/release-request.ts
index 54d57f8..634cfc3 100644
--- a/apps/web/app/lib/releases/release-request.ts
+++ b/apps/web/app/lib/releases/release-request.ts
@@ -3,8 +3,8 @@
* the `X-SkillX-Release` metadata header and the capped tar body. Nothing
* 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 { KIT_ORIGINS, PACKAGE_KINDS, PACKAGE_NAME_PATTERN, RELEASE_CHANNELS, RELEASE_PROVIDERS, RELEASE_VERSION_PATTERN } from "@skillx/contracts";
+import type { KitOrigin, 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 { MAX_RELEASE_METADATA_HEADER_BYTES } from "./release-limits";
@@ -24,9 +24,11 @@ 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;
@@ -36,15 +38,18 @@ function treeErrorToHttp(error: SkillTreeError) {
}
/**
- * 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");
}
@@ -66,6 +71,21 @@ export function parseReleaseName(scope: string | undefined, slug: string | undef
return { identity, handle, slug };
}
+function parseKitReleaseName(scope: string, slug: string): ReleaseName {
+ 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 };
+}
+
function invalidMetadata(message: string) {
return releaseError(400, "release.invalid_metadata", message);
}
@@ -92,6 +112,37 @@ function oneOf(values: readonly T[], value: unknown, fallback:
throw invalidMetadata(`${field} must be one of: ${values.join(", ")}`);
}
+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 };
+}
+
+/**
+ * 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 +169,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/sign-manifest.ts b/apps/web/app/lib/releases/sign-manifest.ts
index 59c5e16..000188b 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,15 @@ 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;
+};
+
+/** The schema version `fields` are signed under. */
+export function manifestSchemaVersionOf(fields: Pick): UnsignedReleaseManifest["schemaVersion"] {
+ return fields.provenance?.provider === "kit" ? SKILLX_RELEASE_SCHEMA_VERSION_V2 : SKILLX_RELEASE_SCHEMA_VERSION;
+}
/** RFC 3339 UTC with whole seconds, as the manifest contract requires. */
export function releaseTimestamp(now: Date): string {
@@ -66,13 +77,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/tests/releases/release-kit-publish.test.ts b/apps/web/tests/releases/release-kit-publish.test.ts
new file mode 100644
index 0000000..bc31049
--- /dev/null
+++ b/apps/web/tests/releases/release-kit-publish.test.ts
@@ -0,0 +1,110 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { releaseTrust, validateReleaseManifest, verifyReleaseManifest } 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 { 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" } } });
+ });
+});
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/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..86fd0d4
--- /dev/null
+++ b/packages/cli/src/commands/release-publish-provenance.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+import { kitProvenance } 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/);
+ });
+});
diff --git a/packages/cli/src/commands/release-publish.ts b/packages/cli/src/commands/release-publish.ts
index e1795e2..546ad4e 100644
--- a/packages/cli/src/commands/release-publish.ts
+++ b/packages/cli/src/commands/release-publish.ts
@@ -48,6 +48,22 @@ export function assertRegistryDigests(
}
}
+const KIT_ORIGINS = ['remote-signed', 'local-dev'] as const;
+
+/**
+ * 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 };
+}
+
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 +76,7 @@ interface PublishOptions {
channel: string;
kind: string;
repo?: string;
+ kitOrigin?: string;
script: string[];
registry?: string;
json?: boolean;
@@ -77,6 +94,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 +103,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');
@@ -102,6 +121,7 @@ export const releasePublishCommand = new Command('publish')
kind: options.kind,
sourceProvenance: repo === null ? null : { repo, commit: packed.commit, path: packed.path },
declaredScripts: [...options.script].sort(),
+ ...(provenance === undefined ? {} : { provenance }),
};
const res = await fetch(`${base}/api/v1/packages/@${name[1]}/${name[2]}/releases`, {
method: 'POST',
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.ts b/packages/contracts/src/release-provenance.ts
new file mode 100644
index 0000000..9470478
--- /dev/null
+++ b/packages/contracts/src/release-provenance.ts
@@ -0,0 +1,94 @@
+/**
+ * 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 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 {
+ /**
+ * True only when the caller verified the AgentKit kit signature of this
+ * exact tree. The SkillX registry signature never counts.
+ */
+ kitSignatureVerified?: boolean;
+}
+
+/**
+ * Lifecycle trust of a verified release manifest. 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 `kitSignatureVerified` is true; otherwise it is
+ * `local-unverified`, as for a `local-dev` kit.
+ */
+export function releaseTrust(
+ manifest: { identity: 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.kitSignatureVerified === true;
+ 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..45e1da1
--- /dev/null
+++ b/packages/contracts/src/release-vectors-v2.test.ts
@@ -0,0 +1,215 @@
+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 { releaseProvenanceOf, 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);
+ }
+ });
+});
+
+describe("releaseTrust", () => {
+ it("grants signed-kit only for a remote-signed kit release with a verified kit signature", () => {
+ const kit = RELEASE_MANIFEST_V2_KIT_FIXTURE;
+ expect(releaseTrust(kit)).toBe("local-unverified");
+ expect(releaseTrust(kit, { kitSignatureVerified: true })).toBe("signed-kit");
+ expect(releaseTrust(RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE, { kitSignatureVerified: true })).toBe("local-unverified");
+ expect(releaseTrust(RELEASE_MANIFEST_V2_SKILLX_FIXTURE, { kitSignatureVerified: true })).toBe("local-unverified");
+ expect(releaseTrust(RELEASE_MANIFEST_FIXTURE, { kitSignatureVerified: true })).toBe("local-unverified");
+ });
+
+ it("reads a v1 manifest as provider skillx", () => {
+ expect(releaseProvenanceOf(RELEASE_MANIFEST_FIXTURE)).toEqual({ provider: "skillx" });
+ expect(releaseProvenanceOf(RELEASE_MANIFEST_V2_KIT_FIXTURE)).toEqual({ provider: "kit", origin: "remote-signed" });
+ });
+});
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";
From b70a1228c088e2ae02d0887a5b0a3622fbdcb4dc Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 19 Oct 2026 13:27:06 +0000
Subject: [PATCH 2/3] docs(releases): document skillx.release/v2 kit provenance
Covers the v2 provenance member, kit release publishing, the
--kit-origin flag and the reserved @agentkit namespace on the
publisher docs page.
Refs #100
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Nti6zFrfo6zR7enh3JrDQy
---
CLAUDE.md | 2 +-
apps/web/app/routes/docs-publishers.tsx | 8 +++++
docs/api-reference.md | 46 +++++++++++++++++++++----
docs/cli-reference.md | 1 +
docs/system-architecture.md | 2 +-
5 files changed, 50 insertions(+), 9 deletions(-)
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/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/docs/api-reference.md b/docs/api-reference.md
index 64c41b7..9a08811 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,29 @@ 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,
+{kitSignatureVerified})` returns `signed-kit` only for `origin:
+"remote-signed"` when the caller verified the AgentKit kit signature of that
+exact tree itself; 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 +991,26 @@ 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`.
+
**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 +1108,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 +1131,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)
│
▼
From 6d54ed2f9dafbc9647a5eae385f5940370d88391 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 19 Oct 2026 13:34:40 +0000
Subject: [PATCH 3/3] refactor(releases): bind kit trust to the release digest
and refuse origin changes on replay
releaseTrust now takes the releaseDigest whose kit signature the caller
verified, so a verification of another tree cannot grant signed-kit.
A kit republish with the same tree but a different origin is 409
instead of a silent success. The stored manifest_schema_version comes
from the signed manifest. Kit request parsing and the tar read move
into their own modules to keep files under 200 lines, and the CLI
reuses KIT_ORIGINS from @skillx/skill-tree.
Refs #100
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Nti6zFrfo6zR7enh3JrDQy
---
apps/web/app/lib/releases/ingest-release.ts | 63 ++++------------
apps/web/app/lib/releases/release-kit.ts | 73 +++++++++++++++++++
apps/web/app/lib/releases/release-request.ts | 40 +---------
.../web/app/lib/releases/release-tree-read.ts | 37 ++++++++++
apps/web/app/lib/releases/sign-manifest.ts | 5 --
.../releases/release-kit-publish.test.ts | 37 +++++++++-
docs/api-reference.md | 11 ++-
.../release-publish-provenance.test.ts | 13 +++-
packages/cli/src/commands/release-publish.ts | 17 +++--
.../contracts/src/release-provenance.test.ts | 29 ++++++++
packages/contracts/src/release-provenance.ts | 23 +++---
.../contracts/src/release-vectors-v2.test.ts | 18 +----
12 files changed, 236 insertions(+), 130 deletions(-)
create mode 100644 apps/web/app/lib/releases/release-kit.ts
create mode 100644 apps/web/app/lib/releases/release-tree-read.ts
create mode 100644 packages/contracts/src/release-provenance.test.ts
diff --git a/apps/web/app/lib/releases/ingest-release.ts b/apps/web/app/lib/releases/ingest-release.ts
index 479ce94..312ceb5 100644
--- a/apps/web/app/lib/releases/ingest-release.ts
+++ b/apps/web/app/lib/releases/ingest-release.ts
@@ -2,31 +2,27 @@
* Release ingest: `POST /api/v1/packages/@handle/slug/releases`.
*
* Order (each step rejects before any later one runs):
- * name and reserved namespaces (for the provider the metadata asks for) -> 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.
- *
- * Kit releases: metadata `provenance: {provider: "kit", origin}` is the only
- * way to publish `@agentkit/*`. It still needs a key scoped
- * `releases:publish:@agentkit` whose owner owns the active, admin-provisioned
- * `agentkit` publisher, and it is signed as `skillx.release/v2`. Every other
- * release is signed as `skillx.release/v1`.
+ * 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 } from "@skillx/contracts";
-import { ERROR_CODES, SkillTreeError, readTarTree } from "@skillx/skill-tree";
-import type { TreeManifest } from "@skillx/skill-tree";
+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 { 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 { manifestSchemaVersionOf, signRelease } from "./sign-manifest";
+import { assertSameKitProvenance } from "./release-kit";
+import { signRelease } from "./sign-manifest";
import type { RegistrySigner } from "./sign-manifest";
export interface IngestDeps {
@@ -38,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,
@@ -102,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)! })));
@@ -162,7 +131,7 @@ async function ingest(request: Request, params: { scope?: string; slug?: string
version: metadata.version,
channel: metadata.channel,
state: scan.state,
- manifestSchemaVersion: manifestSchemaVersionOf(metadata),
+ manifestSchemaVersion: signed.manifest.schemaVersion,
artifactKey: artifactKey(artifactHex),
artifactDigest: `sha256:${artifactHex}`,
artifactSize: body.length,
@@ -178,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 634cfc3..a56d3d9 100644
--- a/apps/web/app/lib/releases/release-request.ts
+++ b/apps/web/app/lib/releases/release-request.ts
@@ -3,10 +3,11 @@
* the `X-SkillX-Release` metadata header and the capped tar body. Nothing
* here touches storage.
*/
-import { KIT_ORIGINS, PACKAGE_KINDS, PACKAGE_NAME_PATTERN, RELEASE_CHANNELS, RELEASE_PROVIDERS, RELEASE_VERSION_PATTERN } from "@skillx/contracts";
-import type { KitOrigin, PackageKind, ReleaseChannel, ReleaseProvenance, ReleaseProvider, ReleaseSourceProvenance } from "@skillx/contracts";
+import { PACKAGE_KINDS, PACKAGE_NAME_PATTERN, RELEASE_CHANNELS, RELEASE_VERSION_PATTERN } 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";
@@ -32,11 +33,6 @@ const METADATA_FIELDS = new Set(["version", "channel", "kind", "sourceProvenance
/** 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 for a release of `provider`. For
* `skillx`, `@agentkit/*` and the `ak-`/`ak:` alias forms are reserved for
@@ -71,21 +67,6 @@ export function parseReleaseName(scope: string | undefined, slug: string | undef
return { identity, handle, slug };
}
-function parseKitReleaseName(scope: string, slug: string): ReleaseName {
- 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 };
-}
-
function invalidMetadata(message: string) {
return releaseError(400, "release.invalid_metadata", message);
}
@@ -112,21 +93,6 @@ function oneOf(values: readonly T[], value: unknown, fallback:
throw invalidMetadata(`${field} must be one of: ${values.join(", ")}`);
}
-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 };
-}
-
/**
* The provider a release request asks for, read before authorization so the
* reserved `@agentkit` namespace is decided first. Only `provenance.provider`
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 000188b..ab5e757 100644
--- a/apps/web/app/lib/releases/sign-manifest.ts
+++ b/apps/web/app/lib/releases/sign-manifest.ts
@@ -58,11 +58,6 @@ export type ManifestFields = Omit): UnsignedReleaseManifest["schemaVersion"] {
- return fields.provenance?.provider === "kit" ? SKILLX_RELEASE_SCHEMA_VERSION_V2 : SKILLX_RELEASE_SCHEMA_VERSION;
-}
-
/** RFC 3339 UTC with whole seconds, as the manifest contract requires. */
export function releaseTimestamp(now: Date): string {
return `${now.toISOString().slice(0, 19)}Z`;
diff --git a/apps/web/tests/releases/release-kit-publish.test.ts b/apps/web/tests/releases/release-kit-publish.test.ts
index bc31049..ff4f36f 100644
--- a/apps/web/tests/releases/release-kit-publish.test.ts
+++ b/apps/web/tests/releases/release-kit-publish.test.ts
@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
-import { releaseTrust, validateReleaseManifest, verifyReleaseManifest } from "@skillx/contracts";
+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 { createHarness, file, tarOf, TEST_KEY_ID } from "./release-harness";
+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`. */
@@ -107,4 +108,36 @@ describe("kit release publish", () => {
});
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/docs/api-reference.md b/docs/api-reference.md
index 9a08811..e1fc41a 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -798,9 +798,10 @@ 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,
-{kitSignatureVerified})` returns `signed-kit` only for `origin:
-"remote-signed"` when the caller verified the AgentKit kit signature of that
-exact tree itself; every other release is `local-unverified`. Golden vectors:
+{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).
@@ -1003,7 +1004,9 @@ Unscoped keys are rejected (`auth.scope_missing`).
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`.
+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
diff --git a/packages/cli/src/commands/release-publish-provenance.test.ts b/packages/cli/src/commands/release-publish-provenance.test.ts
index 86fd0d4..f6b15e7 100644
--- a/packages/cli/src/commands/release-publish-provenance.test.ts
+++ b/packages/cli/src/commands/release-publish-provenance.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { kitProvenance } from './release-publish.js';
+import { kitProvenance, releaseMetadataHeader } from './release-publish.js';
describe('kitProvenance', () => {
it('sends no provenance without --kit-origin, so the release stays skillx.release/v1', () => {
@@ -17,3 +17,14 @@ describe('kitProvenance', () => {
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 546ad4e..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,8 +49,6 @@ export function assertRegistryDigests(
}
}
-const KIT_ORIGINS = ['remote-signed', 'local-dev'] as const;
-
/**
* 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
@@ -64,6 +63,12 @@ export function kitProvenance(publisher: string, kitOrigin: string | undefined):
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;
@@ -115,20 +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 === undefined ? {} : { provenance }),
- };
+ 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/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
index 9470478..d64042d 100644
--- a/packages/contracts/src/release-provenance.ts
+++ b/packages/contracts/src/release-provenance.ts
@@ -11,7 +11,7 @@
* `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 itself.
+ * verified the AgentKit kit signature of that exact tree itself.
*/
import { KIT_ORIGINS, trustOf } from "@skillx/skill-tree/rules";
@@ -70,25 +70,26 @@ export function releaseProvenanceOf(manifest: { provenance?: ReleaseProvenance }
export interface ReleaseTrustOptions {
/**
- * True only when the caller verified the AgentKit kit signature of this
- * exact tree. The SkillX registry signature never counts.
+ * The `releaseDigest` of the tree whose AgentKit kit signature the caller
+ * verified itself. The SkillX registry signature never counts.
*/
- kitSignatureVerified?: boolean;
+ kitVerifiedReleaseDigest?: string;
}
/**
- * Lifecycle trust of a verified release manifest. 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 `kitSignatureVerified` is true; otherwise it is
- * `local-unverified`, as for a `local-dev` kit.
+ * 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; provenance?: ReleaseProvenance },
+ 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.kitSignatureVerified === 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-vectors-v2.test.ts b/packages/contracts/src/release-vectors-v2.test.ts
index 45e1da1..7999e8e 100644
--- a/packages/contracts/src/release-vectors-v2.test.ts
+++ b/packages/contracts/src/release-vectors-v2.test.ts
@@ -16,7 +16,7 @@ import {
verifyReleaseManifest,
} from "./release-manifest";
import type { SkillxReleaseManifest, UnsignedReleaseManifest } from "./release-manifest";
-import { releaseProvenanceOf, releaseTrust } from "./release-provenance";
+import { releaseTrust } from "./release-provenance";
import { signPayload } from "./release-signature";
import { serializeTree } from "@skillx/skill-tree/rules";
import {
@@ -197,19 +197,3 @@ describe("skillx.release/v2 cross-language vectors", () => {
}
});
});
-
-describe("releaseTrust", () => {
- it("grants signed-kit only for a remote-signed kit release with a verified kit signature", () => {
- const kit = RELEASE_MANIFEST_V2_KIT_FIXTURE;
- expect(releaseTrust(kit)).toBe("local-unverified");
- expect(releaseTrust(kit, { kitSignatureVerified: true })).toBe("signed-kit");
- expect(releaseTrust(RELEASE_MANIFEST_V2_LOCAL_KIT_FIXTURE, { kitSignatureVerified: true })).toBe("local-unverified");
- expect(releaseTrust(RELEASE_MANIFEST_V2_SKILLX_FIXTURE, { kitSignatureVerified: true })).toBe("local-unverified");
- expect(releaseTrust(RELEASE_MANIFEST_FIXTURE, { kitSignatureVerified: true })).toBe("local-unverified");
- });
-
- it("reads a v1 manifest as provider skillx", () => {
- expect(releaseProvenanceOf(RELEASE_MANIFEST_FIXTURE)).toEqual({ provider: "skillx" });
- expect(releaseProvenanceOf(RELEASE_MANIFEST_V2_KIT_FIXTURE)).toEqual({ provider: "kit", origin: "remote-signed" });
- });
-});