Task — engineering-spec@1

"Self-hosted OCI registry for .skill bundles — cosign signing + tenant-scoped + immutable tags + 100MB cap + audit"

doneTASK-SKILL-102
module skill · class product · priority p0 · created 2026-05-15 · shipped null
depends on TASK-SKILL-101 · blocks TASK-SKILL-201

§1 — Description (BCP-14 normative)

A self-hosted OCI-compliant registry MUST host .skill bundles. Each interaction:

  1. MUST speak OCI Distribution v1.1 — standard /v2/<name>/manifests/<reference> + /v2/<name>/blobs/<digest> endpoints. Backend: zot (recommended; OCI-native + small footprint).
  2. MUST require cosign signature on every push. Cosign uses Ed25519; per-publisher keypair (private key in publisher's secret store). Publisher signs bundle bytes; registry verifies before storing. Unsigned push → 401 with signature_required.
  3. MUST emit memory rows:
  1. MUST scope namespace by tenant_id: registry path is /v2/<tenant_id>/<skill_id>/manifests/<version>. Pulls require valid JWT (TASK-AUTH-004) AND claims.tenant_id == namespace tenant_id. Cross-tenant attempts → 403 with cross_tenant_blocked.
  2. MUST support immutable tags — once <skill_id>:<version> is published, re-push same tag returns 409 with immutable_tag. Bug-fix releases require version bump (semver discipline).
  3. MUST support cyberos-skill publish <bundle.tar.zst> --version <semver> CLI for ergonomic pushes. Auto-signs via local cosign keypair.
  4. MUST support cyberos-skill pull <skill_id>:<version> CLI for ergonomic pulls. Auto-verifies signature; cached locally.
  5. MUST verify signature on EVERY pull (not just push). Tampered storage backend → pull fails with signature_invalid. Defense-in-depth.
  6. MUST authenticate via TASK-AUTH-004 JWT with claims.scope_grants containing skill:publish or skill:pull. Missing scope → 403.
  7. MUST include Idempotency-Key header support on push (mirrors TASK-AUTH-001 §1 #5). Repeat push with same key + same content → return existing manifest; same key + different content → 409 with idempotency_key_reuse.
  8. MUST enforce 100MB bundle size cap. Bundle > 100MB → 413 PAYLOAD_TOO_LARGE with bundle_too_large.
  9. MUST support quota per tenant (10GB total bundle storage at slice 1; configurable). Above quota → 507 INSUFFICIENT_STORAGE.
  10. MUST complete push p95 < 5s for 10MB bundle (typical size); pull p95 < 2s.
  11. MUST publish manifest format matching OCI v1.1: ``json { "schemaVersion": 2, "mediaType": "application/vnd.cyberos.skill.v1+json", "config": { "mediaType": "application/vnd.cyberos.skill.config.v1+json", "digest": "sha256:...", "size": ... }, "layers": [{ "mediaType": "application/vnd.cyberos.skill.bundle.v1+tar+zst", "digest": "sha256:...", "size": ... }], "annotations": { "world.cyberos.skill.signature": "<base64-cosign-sig>", "world.cyberos.skill.publisher": "<subject_id>", "world.cyberos.skill.published_at": "<iso8601>" } } ``
  12. SHOULD emit OTel metrics:

§2 — Why this design (rationale for humans)

Why self-hosted (DEC-205)? SaaS registries (DockerHub, GHCR) leak skill metadata to the registry operator. Self-hosted keeps tenant-business semantics in-region. zot is OCI-native, small, well-maintained.

Why cosign on every push (DEC-206)? Tampered bundles installed in tenant infrastructure = code execution opportunity. Signature on push + verify on every pull = supply-chain integrity. Per-publisher keypair limits blast radius if one is compromised.

Why immutable tags (DEC-207)? Mutable tags allow silent content changes. Tenant A pulls obs.triage-alert:1.0; later, attacker re-publishes 1.0 with malicious content; tenant A's next pull picks it up. Immutable tags prevent this.

Why tenant-scoped namespace (DEC-208)? Tenant A's skills shouldn't be visible/installable by tenant B. Namespace scoping at registry layer = structural isolation. Cross-tenant attempts at API level → 403.

Why verify on EVERY pull, not just on push (§1 #8)? Storage backend compromise (zot DB tampering) could replace bundle content. Re-verifying on pull catches this. The cost is small (Ed25519 verify is microseconds); the security benefit is large.

Why 100MB bundle cap (§1 #11)? Skills are typically <10MB. 100MB cap catches pathological bundles (huge embedded models, miscellaneous tarballs). Above 100MB, skill packaging probably has a bug.

Why idempotency on push (§1 #10)? Network retries during publish (slow upload, timeout) shouldn't produce 409 on retry. Idempotency-Key lets retry succeed AS the original.

Why p95 budgets (§1 #13)? CI/CD pipelines push skills frequently. 5s push budget keeps deploys fast. 2s pull keeps cold-start of skill execution fast.

Why cyberos-skill CLI (§1 #6 + #7)? Ergonomic UX. Without CLI, publishers use raw oras or crane commands — possible but error-prone. CLI auto-handles cosign + JWT + paths.

Why per-publisher cosign keypair? Publisher identity is part of audit chain. One keypair per publisher means "who signed this" is unambiguous + revocable independently.


§3 — API contract

Endpoints (OCI Distribution v1.1)

POST /v2/<tenant_id>/<skill_id>/blobs/uploads/         # initiate blob upload
PUT  /v2/<tenant_id>/<skill_id>/blobs/uploads/<uuid>?digest=sha256:<>
PUT  /v2/<tenant_id>/<skill_id>/manifests/<version>    # push manifest
GET  /v2/<tenant_id>/<skill_id>/manifests/<version>    # pull manifest
GET  /v2/<tenant_id>/<skill_id>/blobs/sha256:<>        # pull blob
HEAD /v2/<tenant_id>/<skill_id>/manifests/<version>    # check exists (immutability check)

Push handler

// services/skill-registry/src/oci.rs
pub async fn push_manifest(
    tenant_id: Uuid, skill_id: &str, version: &str,
    body: Bytes, claims: &Claims, idempotency_key: Option<String>,
    storage: &Storage, memory: &MemoryBridge,
) -> Result<PushResponse, RegistryError> {
    // §1 #4 tenant scope
    if claims.tenant_id != tenant_id { return Err(RegistryError::CrossTenantBlocked); }
    // §1 #9 scope grant
    if !claims.scope_grants.iter().any(|g| g == "skill:publish" || g == "*") {
        return Err(RegistryError::Forbidden { needed: "skill:publish".into() });
    }

    // §1 #5 immutability
    if storage.manifest_exists(tenant_id, skill_id, version).await? {
        return Err(RegistryError::ImmutableTag { skill_id: skill_id.into(), version: version.into() });
    }

    // §1 #10 idempotency
    let body_hash = hex::encode(sha256(&body));
    if let Some(key) = &idempotency_key {
        if let Some(prior) = storage.idempotency_lookup(key).await? {
            if prior.body_hash != body_hash {
                return Err(RegistryError::IdempotencyKeyReuse);
            }
            return Ok(prior.response);
        }
    }

    let manifest: SkillManifest = serde_json::from_slice(&body)?;

    // §1 #11 size cap
    let total_size: u64 = manifest.layers.iter().map(|l| l.size).sum();
    if total_size > 100 * 1024 * 1024 {
        return Err(RegistryError::BundleTooLarge { actual_bytes: total_size });
    }

    // §1 #2 cosign signature verify
    let signature = manifest.annotations.get("world.cyberos.skill.signature")
        .ok_or(RegistryError::SignatureRequired)?;
    let pubkey_id = claims.subject_id.to_string();
    cosign_verify::verify_signature(&body, signature, &pubkey_id).await
        .map_err(|e| RegistryError::SignatureInvalid(e.to_string()))?;

    // §1 #12 quota
    let used = storage.tenant_used_bytes(tenant_id).await?;
    if used + total_size > tenant_quota(tenant_id) {
        return Err(RegistryError::QuotaExceeded);
    }

    storage.put_manifest(tenant_id, skill_id, version, &body).await?;

    let request_id = format!("registry_{}", ulid::Ulid::new());
    memory.emit(canonical::skill_published(
        tenant_id, skill_id, version, &body_hash, claims.subject_id,
        &pubkey_id, total_size, &request_id,
    )).await?;

    if let Some(key) = idempotency_key {
        storage.idempotency_insert(&key, &body_hash, &response).await?;
    }

    metrics::push(tenant_id, "ok");
    Ok(PushResponse { manifest_digest: body_hash, status: 201 })
}

Pull handler

pub async fn pull_manifest(
    tenant_id: Uuid, skill_id: &str, version: &str, claims: &Claims,
    storage: &Storage, memory: &MemoryBridge,
) -> Result<Bytes, RegistryError> {
    if claims.tenant_id != tenant_id { return Err(RegistryError::CrossTenantBlocked); }
    if !claims.scope_grants.iter().any(|g| g == "skill:pull" || g == "*") {
        return Err(RegistryError::Forbidden { needed: "skill:pull".into() });
    }

    let body = storage.get_manifest(tenant_id, skill_id, version).await?
        .ok_or(RegistryError::NotFound)?;

    // §1 #8 verify on pull (defense in depth)
    let manifest: SkillManifest = serde_json::from_slice(&body)?;
    let signature = manifest.annotations.get("world.cyberos.skill.signature")
        .ok_or(RegistryError::SignatureRequired)?;
    let publisher = manifest.annotations.get("world.cyberos.skill.publisher").cloned().unwrap_or_default();
    cosign_verify::verify_signature(&body, signature, &publisher).await
        .map_err(|e| { metrics::signature_failure("pull"); RegistryError::SignatureInvalid(e.to_string()) })?;

    let request_id = format!("registry_{}", ulid::Ulid::new());
    memory.emit(canonical::skill_pulled(
        tenant_id, skill_id, version, hex::encode(sha256(&body)), claims.subject_id, &request_id,
    )).await?;

    metrics::pull(tenant_id, "ok");
    Ok(body)
}

CLI

// services/skill-registry/src/bin/cyberos_skill.rs
#[derive(clap::Parser)]
struct Cli { #[command(subcommand)] cmd: Cmd }

#[derive(clap::Subcommand)]
enum Cmd {
    Publish { #[arg(long)] bundle: PathBuf, #[arg(long)] version: String },
    Pull    { skill_ref: String },   // "obs.triage-alert:1.0.0"
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Publish { bundle, version } => {
            let bytes = std::fs::read(&bundle)?;
            let signature = cosign::sign_local(&bytes, "~/.cyberos/cosign.key")?;
            let manifest = build_manifest(&bytes, &signature, &version)?;
            let resp = http::push(&manifest, &load_jwt()).await?;
            println!("✅ Published: {}@{}", resp.skill_id, version);
        }
        Cmd::Pull { skill_ref } => {
            let (skill_id, version) = parse_ref(&skill_ref)?;
            let bytes = http::pull(&skill_id, &version, &load_jwt()).await?;
            std::fs::write(format!("./{skill_id}-{version}.tar.zst"), &bytes)?;
            println!("✅ Pulled: {skill_id}@{version}");
        }
    }
    Ok(())
}

§4 — Acceptance criteria

  1. Push .skill bundle with valid cosign sig → 201; manifest stored.
  2. Push without signature → 401 signature_required.
  3. Push with invalid signature → 401 signature_invalid.
  4. Pull → verifies signature; emits skill.pulled audit row.
  5. Re-push same tag → 409 immutable_tag.
  6. Cross-tenant pull (tenant B pulling from tenant A namespace) → 403 cross_tenant_blocked.
  7. Cross-tenant push → 403.
  8. JWT lacks skill:publish → 403.
  9. JWT lacks skill:pull → 403.
  10. memory rows emitted on push (skill.published) and pull (skill.pulled).
  11. Bundle > 100MB → 413 bundle_too_large.
  12. Tenant over 10GB quota → 507 quota_exceeded.
  13. Idempotent push (same key + same content) → returns prior manifest.
  14. Idempotent push (same key + different content) → 409 idempotency_key_reuse.
  15. p95 push < 5s for 10MB bundle.
  16. p95 pull < 2s.
  17. CLI cyberos-skill publish works end-to-end.
  18. CLI cyberos-skill pull works end-to-end.
  19. Pull with tampered storage (manual byte change) → 401 signature_invalid.

§5 — Verification

#[tokio::test]
async fn push_with_valid_signature_succeeds() {
    let bundle = test_helper::build_bundle("obs.triage-alert");
    let sig = cosign::sign(&bundle, &test_keypair());
    let resp = push_manifest(test_tenant(), "obs.triage-alert", "1.0.0",
                              build_manifest(&bundle, &sig), &test_publisher_claims(), None,
                              &test_storage(), &test_memory()).await.unwrap();
    assert_eq!(resp.status, 201);
    assert!(memory_test_helper::has_row("skill.published", "1.0.0").is_some());
}

#[tokio::test]
async fn push_without_signature_returns_401() {
    let bundle = test_helper::build_bundle("x");
    let manifest = build_manifest_unsigned(&bundle);
    let err = push_manifest(test_tenant(), "x", "1.0.0", manifest, &test_publisher_claims(), None, &test_storage(), &test_memory()).await.expect_err("expected SignatureRequired");
    assert!(matches!(err, RegistryError::SignatureRequired));
}

#[tokio::test]
async fn push_with_invalid_signature_returns_401() {
    let bundle = test_helper::build_bundle("x");
    let sig = "TAMPERED_SIGNATURE_BASE64";
    let manifest = build_manifest(&bundle, sig);
    let err = push_manifest(test_tenant(), "x", "1.0.0", manifest, &test_publisher_claims(), None, &test_storage(), &test_memory()).await.expect_err("expected SignatureInvalid");
    assert!(matches!(err, RegistryError::SignatureInvalid(_)));
}

#[tokio::test]
async fn re_push_same_tag_returns_409_immutable() {
    let _ = push_test_manifest("obs.triage-alert", "1.0.0").await.unwrap();
    let err = push_test_manifest("obs.triage-alert", "1.0.0").await.expect_err("expected ImmutableTag");
    assert!(matches!(err, RegistryError::ImmutableTag { .. }));
}

#[tokio::test]
async fn cross_tenant_pull_returns_403() {
    let tenant_a = test_helper::create_tenant().await;
    let tenant_b = test_helper::create_tenant().await;
    let _ = push_with_tenant(tenant_a, "x", "1.0.0").await.unwrap();

    let claims_b = claims_for(tenant_b);
    let err = pull_manifest(tenant_a, "x", "1.0.0", &claims_b, &test_storage(), &test_memory()).await.expect_err("expected CrossTenantBlocked");
    assert!(matches!(err, RegistryError::CrossTenantBlocked));
}

#[tokio::test]
async fn pull_emits_skill_pulled_audit_row() {
    let _ = push_test_manifest("x", "1.0.0").await.unwrap();
    let _ = pull_manifest(test_tenant(), "x", "1.0.0", &test_puller_claims(), &test_storage(), &test_memory()).await.unwrap();
    let row = memory_test_helper::find_latest("skill.pulled").unwrap();
    assert_eq!(row.payload["skill_id"], "x");
    assert_eq!(row.payload["version"], "1.0.0");
}

#[tokio::test]
async fn bundle_over_100mb_returns_413() {
    let huge = vec![0u8; 110 * 1024 * 1024];
    let manifest = build_manifest_with_layer_size(&huge, 110 * 1024 * 1024);
    let err = push_manifest(test_tenant(), "x", "1.0.0", manifest, &test_publisher_claims(), None, &test_storage(), &test_memory()).await.expect_err("expected BundleTooLarge");
    assert!(matches!(err, RegistryError::BundleTooLarge { .. }));
}

#[tokio::test]
async fn tampered_storage_pull_fails_signature_verify() {
    let _ = push_test_manifest("x", "1.0.0").await.unwrap();
    test_helper::tamper_storage_byte(test_tenant(), "x", "1.0.0", 100, 0xff).await;
    let err = pull_manifest(test_tenant(), "x", "1.0.0", &test_puller_claims(), &test_storage(), &test_memory()).await.expect_err("expected SignatureInvalid");
    assert!(matches!(err, RegistryError::SignatureInvalid(_)));
}

#[tokio::test]
async fn idempotent_push_returns_prior_manifest() {
    let key = "idem-001".to_string();
    let manifest = test_manifest("x", "1.0.0");
    let r1 = push_manifest(test_tenant(), "x", "1.0.0", manifest.clone(), &test_publisher_claims(), Some(key.clone()), &test_storage(), &test_memory()).await.unwrap();
    let r2 = push_manifest(test_tenant(), "x", "1.0.0", manifest, &test_publisher_claims(), Some(key), &test_storage(), &test_memory()).await.unwrap();
    assert_eq!(r1.manifest_digest, r2.manifest_digest);
}

§6 — Implementation skeleton

See §3.

# deploy/skill-registry/docker-compose.yml
services:
  zot:
    image: ghcr.io/project-zot/zot:v2.1.0
    ports: ["5000:5000"]
    volumes:
      - ./zot-config.json:/etc/zot/config.json:ro
      - zot-data:/var/lib/registry
  skill-registry:
    build: ../../services/skill-registry
    ports: ["7878:7878"]
    environment: { ZOT_URL: http://zot:5000, MEMORY_URL: http://memory:8080 }
    depends_on: [zot]
volumes: { zot-data: }
{
  "storage": { "rootDirectory": "/var/lib/registry" },
  "http": { "address": "0.0.0.0", "port": "5000",
            "auth": { "htpasswd": { "path": "/etc/zot/htpasswd" }}},
  "extensions": { "search": { "enable": true }}
}

§7 — Dependencies


§8 — Example payloads

Push request (CLI)

$ cyberos-skill publish ./obs-triage-alert.tar.zst --version 1.0.0
✅ Signed with cosign (key: ~/.cyberos/cosign.key)
✅ Published: obs.triage-alert@1.0.0 (digest: sha256:abc123...)

Push manifest

PUT /v2/550e.../obs.triage-alert/manifests/1.0.0 HTTP/1.1
Authorization: Bearer <jwt>
Content-Type: application/vnd.cyberos.skill.v1+json
Idempotency-Key: pub-001

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.cyberos.skill.v1+json",
  "config": { "mediaType": "application/vnd.cyberos.skill.config.v1+json", "digest": "sha256:abc...", "size": 512 },
  "layers": [{ "mediaType": "application/vnd.cyberos.skill.bundle.v1+tar+zst", "digest": "sha256:def...", "size": 8192345 }],
  "annotations": {
    "world.cyberos.skill.signature": "MEQCI...",
    "world.cyberos.skill.publisher": "subject-stephen-...",
    "world.cyberos.skill.published_at": "2026-05-15T14:00:00Z"
  }
}

Audit rows

{
  "kind": "skill.published",
  "payload": {
    "tenant_id": "550e...", "skill_id": "obs.triage-alert", "version": "1.0.0",
    "digest": "sha256:abc...", "publisher_subject_id": "...",
    "signature_pubkey_id": "subject-stephen-...",
    "bundle_size_bytes": 8192345, "request_id": "registry_..."
  }
}

{
  "kind": "skill.pulled",
  "payload": {
    "tenant_id": "550e...", "skill_id": "obs.triage-alert", "version": "1.0.0",
    "digest": "sha256:abc...", "puller_subject_id": "...", "request_id": "registry_..."
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
cosign signature failssigstore verify401 signature_invalidPublisher fixes signing
Bundle too large (>100MB)size check413 bundle_too_largeReduce bundle
Immutable tag existsmanifest_exists check409 immutable_tagBump version
Cross-tenant pushclaims check403 cross_tenant_blockedCaller uses correct tenant JWT
Cross-tenant pullclaims check403Same
JWT lacks skill:publishscope check403 forbiddenGrant scope
Quota exceeded (>10GB)tenant_used check507 quota_exceededOperator extends quota OR delete old
Storage backend errorzot 5xx503Operator investigates
Idempotent replay (same key + same content)lookup201 with prior digestBy design
Idempotency reuse (same key + different content)hash mismatch409 idempotency_key_reuseCaller uses different key
Tampered storage (post-push)pull-time verify catches401 signature_invalidInvestigate storage integrity
memory audit emit failsmemory_writer errorPush succeeds; sev-1 logOperator investigates
zot downhttp error503Restart zot
Publisher key revokedsignature still verifies (key cached)Future pushes failUpdate key allow-list
Bundle integrity corruption (during transfer)digest mismatch400Caller retries
Unknown manifest reference (HEAD nonexistent)404Caller proceeds with PUTBy design
Concurrent push same versionDB unique constraintOne succeeds; other 409By design
CLI cosign key missingfile not foundExit 1 with clear messageUser generates keypair

§11 — Notes


End of TASK-SKILL-102. Status: draft (10/10 target).

As built (2026-07-02)

skill-registry was consolidated into services/skill-broker (src/oci.rs).