Task — engineering-spec@1

"Marketplace distribution — cyberos-plugin publish pushes signed bundle to plugins.cyberskill.world + mirrors to agentskills.io; revenue-share + vetted badge"

draftTASK-PLUGIN-008
module plugin · class product · priority p1 · created 2026-05-19 · shipped null
depends on TASK-PLUGIN-006, TASK-PLUGIN-007 · blocks none

§1 — Description (BCP-14 normative)

The PLUGIN module MUST ship a marketplace publish surface at services/plugin-host/src/marketplace/. CLI cyberos-plugin publish <bundle> uploads a packed bundle to plugins.cyberskill.world (OCI-compatible registry); public plugins additionally mirror to agentskills.io. The marketplace server itself (registry API + UI) is scaffolded here and fully implemented in task-PLUGIN-008a.

  1. MUST implement cyberos-plugin publish <bundle> CLI with flags per DEC-2470 + DEC-2471:
  1. MUST validate bundle integrity before upload per DEC-2475:
  1. MUST push to OCI-compatible registry at plugins.cyberskill.world/v2/<id>/blobs per OCI Distribution Spec v1.1:
  1. MUST mirror public-visibility plugins to agentskills.io per DEC-2472:
  1. MUST support the vetted-by-CyberSkill badge per DEC-2474:
  1. MUST support three visibility modes per DEC-2471:
  1. MUST validate revenue-share rules per DEC-2473:
  1. MUST emit memory audit row plugin.published per DEC-2476 with body containing:
  1. MUST enforce version monotonicity at registry — uploading version 1.0.0 after 1.0.1 MUST fail. SemVer 2.0 semantic ordering per TASK-PLUGIN-001 clause 4.
  1. MUST support cyberos-plugin yank <id>@<version> for emergency removal — yanked versions are hidden from search but remain installable by users with the SHA-256 (so existing installs don't break). Yank emits memory audit plugin.yanked.
  1. MUST NOT publish a bundle that fails any of the 8 INTEROP invariants per DEC-2475 + clause 2.
  1. MUST NOT mirror private or enterprise plugins to agentskills.io per DEC-2472 + clause 4. Private bundles MUST stay on CyberSkill's infrastructure.
  1. MUST NOT allow a paid plugin to set revenue_share_percent > 100 — schema enforces 0-100 range; publish double-checks.
  1. MUST NOT publish without a verified Sigstore signature anchor per clause 2.

§2 — Why this design

Why OCI-compatible registry (DEC-2470, clause 3)? OCI registries are the de-facto standard for bundle distribution. Docker Hub, GitHub Container Registry, AWS ECR all speak OCI. Using OCI gives us free integration with existing tooling (cosign signing, opens up multi-cloud mirroring). Marketplace UI sits on top of the OCI API.

Why three visibility modes (DEC-2471, clause 6)? Strategy Level 3 needs public (anyone installs); Strategy Level 1 OSS amplifies via public mirror; Strategy Level 4 (vertical packs) is mostly public but may be tenant-specific in early stages → private; Strategy Level 5 (enterprise white-label) is enterprise. Three covers all four levels cleanly.

Why mirror to agentskills.io (DEC-2472, clause 4)? Strategy §2 lists agentskills.io as the open Anthropic registry; CyberSkill is a citizen. Public plugins benefit from being discoverable via Anthropic's ecosystem search, not just CyberSkill's. Mirror gives reach without ceding ownership (registry of record remains plugins.cyberskill.world).

Why 70/30 split (DEC-2473, clause 7)? Industry standard (Apple App Store, Shopify post-Shopify Capital, Salesforce AppExchange). 70% is the "fair share" headline; deviation triggers a warning because plugin authors deserve protection from accidental low splits.

Why JWT-signed vetted badge (DEC-2474, clause 5)? Badge claim must be unforgeable. JWT signed by CyberSkill's marketplace key is verifiable client-side (cyberos-plugin doctor rechecks). Manual review at first; automated for security-only plugins later.

Why publish-time re-validation (DEC-2475, clause 2)? Local validation can be tampered with (modified cyberos-plugin binary). Server-side re-validation is the trust anchor. Same reason GitHub re-verifies signed commits.

Why version monotonicity (clause 9)? Out-of-order publish (1.0.1 then 1.0.0) confuses dependency resolvers. Either version is "latest"? Monotonic publish-by-version means latest tag always points at the highest version.

Why yank-not-delete (clause 10)? Permanent deletion breaks existing installs that have the SHA-256. Yank hides from discovery but preserves install ability. Same model as crates.io, npm.

Why audit publish (DEC-2476, clause 8)? Strategy §2 demands every action be audit-chained. Publish is a high-stakes action (introduces code into users' environments). Audit row is forensic record + DSAR-exportable.

Why no mirror for private/enterprise (clause 12)? Private plugins may contain tenant-confidential content (vertical packs for specific clients). Mirroring to a public registry leaks. Hard separation.


§3 — API contract

CLI surface

cyberos-plugin publish <bundle> [--visibility VIS] [--registry URL] [--mirror-agentskills] [--vetted-badge-token TOKEN]
cyberos-plugin yank <id>@<version> [--reason TEXT]
cyberos-plugin list [--visibility VIS] [--vetted]
cyberos-plugin info <id>@<version>

Plugin registry Postgres schema (scaffold for task-PLUGIN-008a server)

-- services/plugin-marketplace/migrations/0001_plugin_registry.sql
CREATE TABLE plugin_marketplace.plugins (
  plugin_id TEXT NOT NULL,
  version TEXT NOT NULL,
  owner_tenant_id UUID NOT NULL,
  visibility TEXT NOT NULL CHECK (visibility IN ('public','private','enterprise')),
  bundle_sha256 BYTEA NOT NULL,
  bundle_size_bytes BIGINT NOT NULL,
  rekor_uuid TEXT NOT NULL,
  vetted_badge_token TEXT,
  vetted_at TIMESTAMPTZ,
  price_usd_per_month NUMERIC(10,2),
  revenue_share_percent SMALLINT NOT NULL DEFAULT 70,
  yanked_at TIMESTAMPTZ,
  yanked_reason TEXT,
  published_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_by_subject UUID NOT NULL,
  trace_id CHAR(32),
  PRIMARY KEY (plugin_id, version)
);

CREATE INDEX ON plugin_marketplace.plugins (visibility, published_at DESC)
  WHERE yanked_at IS NULL;
CREATE INDEX ON plugin_marketplace.plugins (owner_tenant_id, visibility);
CREATE INDEX ON plugin_marketplace.plugins (vetted_at) WHERE vetted_at IS NOT NULL;

Publish flow

// services/plugin-host/src/marketplace/publish.rs
pub async fn publish(
    bundle_path: &Path,
    visibility: Visibility,
    registry: &Url,
    mirror_agentskills: bool,
    vetted_badge_token: Option<&str>,
) -> Result<PublishResult> {
    // Step 1: validate bundle (re-run doctor + manifest schema)
    let invariants = doctor(bundle_path)?;
    if !invariants.all_pass() {
        return Err(PublishError::InvariantViolation(invariants.failures));
    }

    // Step 2: parse manifest
    let manifest = read_canonical_manifest_from_bundle(bundle_path)?;

    // Step 3: verify Sigstore anchor matches bundle bytes
    sigstore::verify(bundle_path, &manifest.signature.rekor_uuid).await?;

    // Step 4: push to plugins.cyberskill.world OCI
    let push_result = oci_push(bundle_path, &manifest, registry).await?;

    // Step 5: mirror to agentskills.io if public
    let mirror_status = if visibility == Visibility::Public && mirror_agentskills {
        mirror_agentskills::push(&manifest, bundle_path).await.into()
    } else {
        MirrorStatus::Skipped
    };

    // Step 6: emit memory audit
    audit::emit_published(
        &manifest, &push_result.sha256, visibility, mirror_status.clone(),
    ).await?;

    Ok(PublishResult {
        plugin_id: manifest.id,
        version: manifest.version,
        sha256: push_result.sha256,
        rekor_uuid: manifest.signature.rekor_uuid,
        mirror_status,
        vetted_badge: vetted_badge_token.is_some(),
    })
}

Vetted-badge token format

JWT (RS256) signed by CyberSkill marketplace key:
  iss = "https://plugins.cyberskill.world"
  aud = "plugin:<id>:<version>"
  sub = <reviewer_subject_id>
  iat, exp (typically 90-day validity)
  body:
    review_id = "REV-2026-...",
    review_date = "...",
    findings_summary = "..."

Mirror request to agentskills.io

POST https://agentskills.io/v1/skills/publish HTTP/1.1
Content-Type: application/json
Authorization: Bearer <agentskills.io API key for CyberSkill org>

{
  "publisher": "cyberskill",
  "skill_id": "cyberos",
  "version": "1.0.0",
  "skill_md_url": "https://plugins.cyberskill.world/cyberos/1.0.0/SKILL.md",
  "license": "Apache-2.0",
  "description": "..."
}

§4 — Acceptance criteria

  1. publish rejects bundle failing doctor invariants — mock a bundle with broken signature; publish exits 1.
  2. publish rejects unsigned bundle — bundle missing Sigstore anchor; publish exits 1.
  3. publish pushes to OCI registry — mock registry receives PUT to /v2/cyberos/blobs/uploads/.
  4. publish for public mirrors to agentskills.io — mock agentskills.io receives mirror call.
  5. publish for private does NOT mirror — mock agentskills.io receives 0 calls.
  6. publish emits plugin.published audit — memory row exists with kind='plugin.published'.
  7. plugin.published body has version + sha256 + rekor_uuid + visibility — body field check.
  8. Out-of-order version rejected — publish 1.0.1; publish 1.0.0 next; second fails.
  9. Yank hides from default search — publish; yank; cyberos-plugin list does not show yanked.
  10. Yanked plugin still installable by SHA-256 — install by hash succeeds.
  11. Vetted badge persists on infocyberos-plugin info <id>@<version> shows vetted: true when token verified.
  12. Bad vetted badge token rejected — JWT signed by wrong key; publish stores but info shows vetted: false with reason.
  13. Private plugin cross-tenant install denied — tenant B trying to install tenant A's private plugin fails.
  14. Enterprise plugin appears only at enterprise originplugins.cyberskill.world does NOT list it; plugins.acme.cyberskill.world does.
  15. revenue_share_percent < 70 triggers warning — publish-time stderr warning; publish still succeeds.
  16. revenue_share_percent > 100 rejected — schema + publish double-check fails.
  17. Mirror failure is soft — agentskills.io 500s; publish still succeeds; mirror row queued in retry table.
  18. Publish audit row scrubbed — body MUST NOT contain bundle bytes (only sha256).
  19. info returns visibility, vetted status, version list — RPC works.
  20. list --vetted filters to vetted-only — query semantics.
  21. OCI blob has correct media typeapplication/vnd.cyberskill.plugin.v1+zip.
  22. Publish requires OAuth-PKCE bearer token — anonymous publish fails 401.

§5 — Verification

// services/plugin-host/tests/publish_invariant_check_test.rs
#[tokio::test]
async fn publish_rejects_unsigned_bundle() {
    let bundle = pack_bundle_without_signature().await;
    let result = publish(&bundle, Visibility::Public, &mock_registry_url(), false, None).await;
    assert!(matches!(result, Err(PublishError::InvariantViolation(_))));
}

#[tokio::test]
async fn publish_rejects_broken_doctor() {
    let bundle = pack_bundle_with_wrong_tool_naming().await;
    let result = publish(&bundle, Visibility::Public, &mock_registry_url(), false, None).await;
    assert!(matches!(result, Err(PublishError::InvariantViolation(_))));
}
// services/plugin-host/tests/publish_mirrors_to_agentskills_test.rs
#[tokio::test]
async fn public_publish_mirrors() {
    let agentskills_mock = MockAgentSkillsServer::start().await;
    let bundle = pack_valid_bundle().await;
    publish(&bundle, Visibility::Public, &mock_registry_url(), true,
            Some("https://localhost/agentskills")).await.unwrap();
    let calls = agentskills_mock.recorded_calls();
    assert_eq!(calls.len(), 1);
    assert_eq!(calls[0].path, "/v1/skills/publish");
}

#[tokio::test]
async fn private_publish_does_not_mirror() {
    let agentskills_mock = MockAgentSkillsServer::start().await;
    let bundle = pack_valid_bundle().await;
    publish(&bundle, Visibility::Private, &mock_registry_url(), true, None).await.unwrap();
    assert_eq!(agentskills_mock.recorded_calls().len(), 0);
}
// services/plugin-host/tests/publish_emits_audit_test.rs
#[tokio::test]
async fn publish_emits_audit_row() {
    let ctx = TestContext::with_memory_mock().await;
    let bundle = pack_valid_bundle().await;
    publish(&bundle, Visibility::Public, &ctx.registry, false, None).await.unwrap();
    let rows = ctx.memory.fetch_rows(kind="plugin.published").await;
    assert_eq!(rows.len(), 1);
    let body = &rows[0]["body"];
    assert_eq!(body["plugin_id"], "cyberos");
    assert_eq!(body["version"], "1.0.0");
    assert_eq!(body["visibility"], "public");
    assert!(body["sha256"].as_str().unwrap().len() == 64);
    assert!(body["rekor_uuid"].is_string());
    // body MUST NOT contain bundle bytes
    let body_str = serde_json::to_string(body).unwrap();
    assert!(!body_str.contains("PK\x03\x04")); // zip magic
}
// services/plugin-host/tests/private_visibility_scoped_to_tenant_test.rs
#[tokio::test]
async fn cross_tenant_private_install_denied() {
    let ctx_a = TestContext::for_tenant("a").await;
    let bundle = ctx_a.pack_and_publish(Visibility::Private).await;

    let ctx_b = TestContext::for_tenant("b").await;
    let result = ctx_b.install_by_id(&bundle.plugin_id, &bundle.version).await;
    assert!(matches!(result, Err(InstallError::NotFound)));
}

§6 — Implementation skeleton

(API contract + Postgres schema in §3 are the skeleton. Full marketplace server implementation deferred to task-PLUGIN-008a, which fleshes out search, browse UI, billing integration with TASK-TEN-005, and admin review tooling.)


§7 — Dependencies


§8 — Example payloads

Publish result

{
  "plugin_id": "cyberos",
  "version": "1.0.0",
  "sha256": "a1b2c3d4...",
  "rekor_uuid": "24296fb24b8ad77a...",
  "mirror_status": "mirrored",
  "vetted_badge": true,
  "registry_url": "https://plugins.cyberskill.world/cyberos/1.0.0"
}

plugin.published audit body

{
  "plugin_id": "cyberos",
  "version": "1.0.0",
  "sha256": "a1b2c3d4...",
  "rekor_uuid": "24296fb...",
  "visibility": "public",
  "vetted_badge": true,
  "mirror_targets": ["agentskills.io"],
  "size_bytes": 184320,
  "trace_id": "01HX..."
}

Vetted badge JWT (decoded)

{
  "iss": "https://plugins.cyberskill.world",
  "aud": "plugin:cyberos:1.0.0",
  "sub": "reviewer-uuid-...",
  "iat": 1748000000,
  "exp": 1755800000,
  "review_id": "REV-2026-042",
  "review_date": "2026-05-19",
  "findings_summary": "All TASK-PLUGIN-001..007 invariants pass; manual review passed."
}

§9 — Open questions

All resolved.


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Bundle doctor invariants failpublish.rs step 1exit 1 with detailsRe-pack bundle correctly
Sigstore anchor mismatchsigstore::verifyexit 1Re-sign bundle
Registry unreachableOCI clientexit 1Retry with backoff (manual)
agentskills.io unreachablemirror callsoft fail; outbox queueMirror retries via TASK-PLUGIN-006 outbox
Out-of-order versionregistry monotonicity checkexit 1Use higher version number
Yank without permissionregistry RBACexit 1Owner-or-admin only
Vetted badge token wrong sigbadge_verifypublish proceeds; badge: false; warningGet badge re-issued
Vetted badge token expiredbadge_verify exp checkbadge: falseRenew via review process
Private plugin published to public originvisibility checkexit 1Use visibility=private + correct registry
Enterprise plugin to wrong enterprise originorigin checkexit 1Use the correct enterprise registry URL
Bundle byte change after signsigstore re-verifyexit 1Re-sign
Cross-tenant private installregistry RLS404inherent
revenue_share > 100schema + double-checkexit 1Author fixes
Audit emission failsTASK-PLUGIN-006 outbox pathpublish succeeds; audit queuedInherent retry
Publisher token lacks scopeOAuth scope check401Re-authenticate with publish scope

§11 — Implementation notes


End of TASK-PLUGIN-008 spec.