Task — engineering-spec@1
"SKILL OCI registry deploy for `.skill` bundles — R3 distribution stage with signed bundles + tag immutability + tenant-scoped pulls"
doneTASK-SKILL-201
§1 — Description (BCP-14 normative)
The SKILL service MUST ship OCI registry deploy at services/skill/src/oci/ with push/pull + cosign signing + tag immutability + tenant ACL, 5 memory audit kinds.
- MUST validate
bundle_statusagainst closed enum per DEC-2421.
- MUST push at
push.rs::push(bundle_path, registry, tag, tenant_acl)per DEC-2420:
- Validate
.skillbundle (SKILL.md frontmatter, allowed_tools list) - Push to OCI registry via OCI distribution spec API
- Sign via
cosign_wrapper.rs::sign(digest, tenant_kms_key) - Record in DB with tenant_acl
- MUST enforce tag immutability per DEC-2423 — pre-push: check registry for existing tag; reject if exists.
- MUST verify cosign signature on pull at
pull.rs::pull(registry, tag)per DEC-2422 —cosign_wrapper.rs::verify(bundle, signature)against registered key; reject + sev-1 audit on mismatch.
- MUST check tenant ACL per DEC-2424 at
acl_check.rs::can_pull(bundle, requester_tenant)— match against bundle.tenant_acl JSONB.
- MUST support yank per DEC-2423 — sets status=yanked; pull returns warning but bundle still retrievable for audit.
- MUST define table at migration
0010: ``sql CREATE TABLE skill_oci_bundles ( bundle_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, registry TEXT NOT NULL, image_name TEXT NOT NULL, tag TEXT NOT NULL, digest TEXT NOT NULL, cosign_signature_bytes BYTEA, tenant_acl JSONB NOT NULL, -- {"allowed_tenants": ["uuid1", "uuid2"], "public": false} status TEXT NOT NULL DEFAULT 'pushed' CHECK (status IN ('pushed','signed','validated','available','deprecated','yanked')), pushed_by UUID NOT NULL, yanked_at TIMESTAMPTZ, yanked_reason TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (registry, image_name, tag) ); ALTER TABLE skill_oci_bundles ENABLE ROW LEVEL SECURITY; CREATE POLICY bundles_rls ON skill_oci_bundles USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON skill_oci_bundles FROM cyberos_app; GRANT UPDATE (status, yanked_at, yanked_reason) ON skill_oci_bundles TO cyberos_app;``
- MUST expose endpoints: ``
text POST /v1/skill/oci/push body: {bundle_path, registry, tag, tenant_acl} POST /v1/skill/oci/pull body: {registry, image_name, tag} POST /v1/skill/oci/yank/{id} body: {reason} GET /v1/skill/oci/bundles (list with ACL filter)``
- MUST emit 5 memory audit kinds per DEC-2425. PII per TASK-MEMORY-111: digest + signature_bytes SHA256.
- MUST thread trace_id from push/pull → audit.
- MUST NOT overwrite existing tag per DEC-2423 (UNIQUE constraint enforces).
- MUST NOT pull without signature verify per DEC-2422.
- MUST NOT allow cross-ACL pull per DEC-2424.
§2 — Why this design
Why OCI (DEC-2420)? Industry standard; GHCR/ECR/etc. interoperable; cosign signing supports it natively.
Why cosign (DEC-2422)? Supply chain integrity; CNCF-graduated; widely audited.
Why tag immutability (DEC-2423)? Reproducibility — same tag must always pull same bytes; otherwise downstream caching breaks.
Why ACL (DEC-2424)? Multi-tenant SaaS — each tenant's custom skills shouldn't leak across.
§3 — API contract
Sample push:
POST /v1/skill/oci/push
{
"bundle_path": "./my-skill.skill",
"registry": "ghcr.io/cyberskill",
"tag": "calendar-list@1.2.0",
"tenant_acl": {"allowed_tenants": ["uuid-1"], "public": false}
}
§4 — Acceptance criteria
- bundle_status enum cardinality 6. 2. Push to OCI works. 3. Cosign signature applied. 4. Tag overwrite rejected (UNIQUE). 5. Pull verifies signature. 6. Bad signature → sev-1 + reject. 7. ACL enforced on pull. 8. Yank sets status, doesn't delete. 9. 5 memory audit kinds emitted. 10. PII scrubbed (digest+sig SHA256). 11. RLS denies cross-tenant. 12. CTO-only push. 13. Trace_id preserved. 14. Append-only via REVOKE except status cols. 15. Cosign key from TASK-AUTH-105. 16. public:true bundles pullable by anyone. 17. public:false requires ACL match. 18. List endpoint filters by ACL. 19. OCI spec compliance. 20. Bundle validation pre-push.
§5 — Verification
#[tokio::test]
async fn tag_immutability() {
let ctx = TestContext::with_pushed_bundle().await;
let r = ctx.try_push_same_tag().await;
assert!(r.is_err());
}
#[tokio::test]
async fn signature_verify_on_pull() {
let ctx = TestContext::with_signed_bundle().await;
let r = ctx.pull(ctx.registry, ctx.tag).await;
assert!(r.is_ok());
ctx.tamper_bundle(ctx.bundle_id).await;
let r2 = ctx.try_pull(ctx.registry, ctx.tag).await;
assert!(r2.is_err());
}
#[tokio::test]
async fn acl_blocks_cross_tenant() {
let ctx = TestContext::with_private_bundle_for_tenant_a().await;
let r = ctx.try_pull_as_tenant_b(ctx.bundle_id).await;
assert_eq!(r.status_code, 403);
}
// 5.4..5.10
§7 — Dependencies
Upstream: TASK-SKILL-102. Cross-module: TASK-AUTH-105 (KMS for cosign), TASK-MEMORY-111 (PII).
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Registry unreachable | retry | sev-2 | retry |
| Tag overwrite | UNIQUE | 409 | new tag |
| Signature mismatch | verify | reject + sev-1 | investigate |
| ACL miss | gate | 403 | get permission |
| Cosign key expired | KMS | sev-1 | rotate |
| Bundle malformed | pre-push validate | 400 | fix bundle |
| Network partition pull | retry | sev-2 | inherent |
| Yank of in-use bundle | warn but allow | inherent | inherent |
| Cross-tenant push | RLS | inherent | inherent |
| Quota exceeded on registry | sev-2 | inherent | upgrade |
§11 — Implementation notes
- §11.1 OCI client:
oci-distributionRust crate; supports GHCR + ECR + Quay. - §11.2 Cosign integration: shell out to
cosignbinary or usecosign-rscrate. - §11.3 memory audit body: bundle_id, registry, tag; digest + signature SHA256.
- §11.4 Tenant ACL stored as JSONB; tomorrow extensible to org-scoped + role-scoped.
- §11.5 Yank semantics: status=yanked + warning on pull; bundle still retrievable for replay.
End of TASK-SKILL-201 spec.