Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 21 additions & 44 deletions apps/web/app/lib/releases/ingest-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@
* Release ingest: `POST /api/v1/packages/@handle/slug/releases`.
*
* Order (each step rejects before any later one runs):
* name and reserved namespaces -> scoped key and publisher ownership ->
* name and reserved namespaces (for the requested provider) -> scoped key and publisher ownership ->
* signing key -> metadata -> capped upload -> tar read with limits and tree
* digest -> version conflict -> exec manifest -> scan -> signed manifest -> R2 artifact, blobs
* and manifest (create-only) -> D1 row (last). Release bytes are stored
* exactly as uploaded; nothing is sanitized.
* exactly as uploaded; nothing is sanitized. Kit releases (`@agentkit/*`,
* see release-kit.ts) are signed as `skillx.release/v2`, all others as v1.
*/
import { ReleaseManifestError, SKILLX_RELEASE_SCHEMA_VERSION } from "@skillx/contracts";
import { ERROR_CODES, SkillTreeError, readTarTree } from "@skillx/skill-tree";
import type { TreeManifest } from "@skillx/skill-tree";
import { ReleaseManifestError } from "@skillx/contracts";
import type { ReleaseProvenance } from "@skillx/contracts";
import { authorizeReleasePublisher } from "./release-auth";
import { checkExecManifest } from "./release-exec";
import { ReleaseHttpError, errorResponse, releaseError } from "./release-http";
import { BLOB_WRITE_CONCURRENCY, MAX_RELEASE_UPLOAD_BYTES, RELEASE_SERVER_TREE_LIMITS, assertSubrequestBudget } from "./release-limits";
import { RELEASE_METADATA_HEADER, parseReleaseMetadata, parseReleaseName, readUploadBody } from "./release-request";
import { BLOB_WRITE_CONCURRENCY, MAX_RELEASE_UPLOAD_BYTES, assertSubrequestBudget } from "./release-limits";
import { RELEASE_METADATA_HEADER, parseReleaseMetadata, parseReleaseName, readUploadBody, requestedProvider } from "./release-request";
import { scanRelease } from "./release-scan";
import { readTree, sha256Hex } from "./release-tree-read";
import { findPackage, findRelease, insertRelease } from "./release-store";
import type { ExistingRelease } from "./release-store";
import { artifactKey, blobKey, forEachLimited, manifestKey, putCreateOnly } from "./r2-create-only";
import { assertSameKitProvenance } from "./release-kit";
import { signRelease } from "./sign-manifest";
import type { RegistrySigner } from "./sign-manifest";

Expand All @@ -32,42 +34,14 @@ export interface IngestDeps {
subrequestBudget?: number;
}

interface ReadTree {
tree: TreeManifest;
contents: Map<string, Uint8Array>;
}

async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
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<ReadTree> {
const seen = new Map<string, Uint8Array>();
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<string, Uint8Array>();
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<Response> {
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,
Expand All @@ -80,10 +54,11 @@ function existingOutcome(identity: string, version: string, existing: ExistingRe
}

async function ingest(request: Request, params: { scope?: string; slug?: string }, deps: IngestDeps): Promise<Response> {
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`);
Expand All @@ -95,7 +70,8 @@ async function ingest(request: Request, params: { scope?: string; slug?: string
const body = await readUploadBody(request, MAX_RELEASE_UPLOAD_BYTES);
const { tree, contents } = await readTree(body);
const existing = await findRelease(deps.db, name.identity, metadata.version);
if (existing !== null) return existingOutcome(name.identity, metadata.version, existing, tree.digest);
const kit = { bucket: deps.bucket, provenance: metadata.provenance };
if (existing !== null) return existingOutcome(name.identity, metadata.version, existing, tree.digest, kit);

const exec = await checkExecManifest(contents, metadata.declaredScripts);
const scan = scanRelease(tree.files.map((f) => ({ path: f.path, bytes: contents.get(f.path)! })));
Expand All @@ -119,6 +95,7 @@ async function ingest(request: Request, params: { scope?: string; slug?: string
publisher: { handle: name.handle, verifiedBy: "admin" },
sourceProvenance: metadata.sourceProvenance,
declaredScripts: metadata.declaredScripts,
provenance: metadata.provenance,
},
signer,
now,
Expand Down Expand Up @@ -154,7 +131,7 @@ async function ingest(request: Request, params: { scope?: string; slug?: string
version: metadata.version,
channel: metadata.channel,
state: scan.state,
manifestSchemaVersion: SKILLX_RELEASE_SCHEMA_VERSION,
manifestSchemaVersion: signed.manifest.schemaVersion,
artifactKey: artifactKey(artifactHex),
artifactDigest: `sha256:${artifactHex}`,
artifactSize: body.length,
Expand All @@ -170,7 +147,7 @@ async function ingest(request: Request, params: { scope?: string; slug?: string
const winner = await findRelease(deps.db, name.identity, metadata.version);
// The release insert matched no package row: one with this name was created concurrently by another publisher or kind.
if (winner === null) throw releaseError(409, "release.kind_conflict", `${name.identity} was created concurrently with another kind or publisher`);
return existingOutcome(name.identity, metadata.version, winner, tree.digest);
return existingOutcome(name.identity, metadata.version, winner, tree.digest, kit);
}
return Response.json({
identity: name.identity,
Expand Down
73 changes: 73 additions & 0 deletions apps/web/app/lib/releases/release-kit.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<void> {
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}`);
}
40 changes: 29 additions & 11 deletions apps/web/app/lib/releases/release-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
* here touches storage.
*/
import { PACKAGE_KINDS, PACKAGE_NAME_PATTERN, RELEASE_CHANNELS, RELEASE_VERSION_PATTERN } from "@skillx/contracts";
import type { PackageKind, ReleaseChannel, ReleaseSourceProvenance } from "@skillx/contracts";
import type { PackageKind, ReleaseChannel, ReleaseProvenance, ReleaseProvider, ReleaseSourceProvenance } from "@skillx/contracts";
import { ERROR_CODES, SkillTreeError, checkDirectoryAlias, checkPublisher, validateIdentity } from "@skillx/skill-tree";
import { releaseError } from "./release-http";
import { parseKitReleaseName, parseProvenance, treeErrorToHttp } from "./release-kit";
import { MAX_RELEASE_METADATA_HEADER_BYTES } from "./release-limits";

export const RELEASE_METADATA_HEADER = "X-SkillX-Release";
Expand All @@ -24,27 +25,27 @@ export interface ReleaseMetadata {
kind: PackageKind;
sourceProvenance: ReleaseSourceProvenance | null;
declaredScripts: string[];
/** `kit` releases are signed as `skillx.release/v2`; null means provider `skillx` (v1). */
provenance: ReleaseProvenance | null;
}

const METADATA_FIELDS = new Set(["version", "channel", "kind", "sourceProvenance", "declaredScripts"]);
const METADATA_FIELDS = new Set(["version", "channel", "kind", "sourceProvenance", "declaredScripts", "provenance"]);
/** The AgentKit alias prefixes (`ak-plan`, `ak:plan`), compared with ASCII case folding. */
const RESERVED_ALIAS_PREFIX = /^ak[-:]/i;

function treeErrorToHttp(error: SkillTreeError) {
const reserved = error.code === ERROR_CODES.identityReservedPublisher || error.code === ERROR_CODES.identityReservedAlias;
return releaseError(reserved ? 403 : 400, error.code, error.message);
}

/**
* Parses `@handle/slug` from the route. `@agentkit/*` and the `ak-`/`ak:`
* alias forms are reserved for AgentKit kit content and fail with 403 before
* any other check; a malformed name fails with 400.
* Parses `@handle/slug` from the route for a release of `provider`. For
* `skillx`, `@agentkit/*` and the `ak-`/`ak:` alias forms are reserved for
* AgentKit kit content and fail with 403 before any other check. A `kit`
* release must be `@agentkit/*` (else 400 `identity.invalid`). A malformed
* name fails with 400.
*/
export function parseReleaseName(scope: string | undefined, slug: string | undefined): ReleaseName {
export function parseReleaseName(scope: string | undefined, slug: string | undefined, provider: ReleaseProvider = "skillx"): ReleaseName {
if (!scope?.startsWith("@") || !slug) {
throw releaseError(400, ERROR_CODES.identityInvalid, "package name must be @publisher/slug");
}
const handle = scope.slice(1);
if (provider === "kit") return parseKitReleaseName(scope, slug);
if (handle.toLowerCase() === "agentkit") {
throw releaseError(403, ERROR_CODES.identityReservedPublisher, "the @agentkit publisher is reserved for AgentKit kit content");
}
Expand Down Expand Up @@ -92,6 +93,22 @@ function oneOf<T extends string>(values: readonly T[], value: unknown, fallback:
throw invalidMetadata(`${field} must be one of: ${values.join(", ")}`);
}

/**
* The provider a release request asks for, read before authorization so the
* reserved `@agentkit` namespace is decided first. Only `provenance.provider`
* is read; a header that does not decode counts as `skillx`, and
* `parseReleaseMetadata` reports every invalid value later.
*/
export function requestedProvider(header: string | null): ReleaseProvider {
try {
const provenance = decodeMetadataHeader(header).provenance;
const kit = typeof provenance === "object" && provenance !== null && (provenance as Record<string, unknown>).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,
Expand All @@ -118,6 +135,7 @@ export function parseReleaseMetadata(header: string | null): ReleaseMetadata {
kind: oneOf(PACKAGE_KINDS, raw.kind, "skill", "kind"),
sourceProvenance: provenance as ReleaseSourceProvenance | null,
declaredScripts: scripts as string[],
provenance: parseProvenance(raw.provenance),
};
}

Expand Down
37 changes: 37 additions & 0 deletions apps/web/app/lib/releases/release-tree-read.ts
Original file line number Diff line number Diff line change
@@ -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<string, Uint8Array>;
}

export async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
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<ReadTree> {
const seen = new Map<string, Uint8Array>();
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<string, Uint8Array>();
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);
}
}
Loading
Loading