oci/plugins: Phase 1 OCI artifact package for plugins (THV-0077)#136
Conversation
Add the `oci/plugins` package mirroring `oci/skills` file-for-file, built on the shared `oci/artifact` primitives from Phase 0. It packages a Claude Code plugin directory (`.claude-plugin/plugin.json` bundling commands, agents, skills, hooks, MCP/LSP server configs) into a reproducible, content-addressable multi-platform OCI artifact, and provides ORAS push/pull and a local store rooted at `toolhive/plugins`. - mediatypes.go: ArtifactTypePlugin "dev.toolhive.plugins.v1"; dev.toolhive.plugins.* labels/annotations including the plugin-specific .components inventory and .requires; PluginConfig + PluginConfigFromImageConfig. - interfaces.go: RegistryClient (Push/Pull), PluginPackager. - packager.go: index -> per-platform manifest -> config + single plugin.tar.gz layer, SOURCE_DATE_EPOCH-aware for reproducible digests. - registry.go: ORAS push/pull + Docker credential auth. - store.go: local OCI store under xdg.DataHome. - mocks/ via go:generate mockgen. Tests cover the exit gates (packager determinism, config round-trip, store put/get) plus end-to-end package -> push -> pull -> extract integration tests. Coverage 73.6%. Part of stacklok/toolhive#5525 Closes #131 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
Review: oci/plugins package
This review combines findings from three parallel specialist agents (security, Go quality, architecture) plus the MoE review in REVIEW-e36cae86.md. The package is a well-structured mirror of oci/skills and the plugin-specific logic is clean. Three issues should be addressed before merge.
Blockers
- B1 — path traversal in
deleteBlobvia unvalidated digest (arbitrary file deletion) - B2 —
os.ReadFilebefore size check enables memory exhaustion; same line lacksLstatguard (symlink TOCTOU) - B3 —
Pulluses short ref as local tag, causing cross-plugin tag collisions in the shared store
Other significant findings
validatePluginDirpath-traversal check is logically ineffective afterfilepath.CleanfetchContentuses unboundedio.ReadAllon local blobsSOURCE_DATE_EPOCHnot applied to gzip member header- Zero-value
PackageOptions.Epochproduces year-1 timestamps in config but 1970 in tar/gzip maxPluginTotalSize(100 MB) + tar overhead can exceedMaxDecompressedSize(100 MB)- File modes stripped to 0644 — executable scripts lose their mode
- No HTTP retry client in
defaultNewTarget DeleteBuildread-modify-write is not atomicjson.Marshalerrors silently discarded in config/manifest creationTestStore_DeleteBuildentirely absent — blob-deletion surface (5 functions) is 0% covered
Findings marked "same in skills" should be fixed in both packages.
Addresses jhrozek's review on PR #136. Blockers (plugins): - B1: validate digest before deriving blob path in Store.deleteBlob, plus a store-root prefix guard, preventing path traversal / arbitrary file deletion. - B2: Lstat + size-check the plugin manifest before reading it, rejecting a symlinked manifest (TOCTOU) and avoiding oversized allocation. - B3: Pull tags the local store under the full OCI reference instead of the bare tag, preventing cross-plugin tag collisions in the shared store. Other findings (plugins): - validatePluginDir: absolute-path guard (the post-Clean ".." check was inert). - fetchContent: bound local reads with io.LimitReader(MaxBlobSize). - gzip member header now honours opts.Epoch. - normalize zero-value PackageOptions.Epoch so config/tar/gzip agree. - maxPluginTotalSize lowered to 95 MB so tar overhead can't exceed the extraction-time MaxDecompressedSize limit. - preserve file modes through packaging (executable scripts stay executable). - retry.DefaultClient for transient registry errors. - DeleteBuild/DeleteTag/Tag serialized under a mutex. - json.Marshal of OCI labels/annotations now panics on the (invariant) error. Tests (plugins): ported TestStore_DeleteBuild, added an index-branch case, a deleteBlob invalid-digest guard, and a file-mode preservation test. Coverage 73.6% -> 81.7%. Parity fixes applied to oci/skills for findings marked "same in skills": B1, B3, fetchContent bound, gzip epoch, total-size limit, retry client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough review @jhrozek. All findings addressed in 169911d. Summary: Blockers (plugins)
Other findings (plugins)
Tests — ported Parity (oci/skills) — applied the findings you marked "same in skills": B1, B3, A couple of skills-only parity items I did not pull into this PR, to keep it scoped — happy to do either here or as a follow-up if you'd prefer:
|
What
Adds the
oci/pluginspackage — Phase 1 of the plugin lifecycle epic (THV-0077). It mirrorsoci/skillsfile-for-file, built on the sharedoci/artifactprimitives extracted in Phase 0.A plugin is a directory declared by
.claude-plugin/plugin.json, bundling slash commands, subagents, Agent Skills, hooks, and MCP/LSP server configs. This package builds such a directory into a reproducible, content-addressable multi-platform OCI artifact, pushes/pulls it via ORAS, and stores it locally.Deliverables (per #131)
mediatypes.go—ArtifactTypePlugin = "dev.toolhive.plugins.v1"; fulldev.toolhive.plugins.*label/annotation set (.name,.description,.version,.license,.files, plus plugin-specific.componentsinventory and.requires);PluginConfig+PluginConfigFromImageConfig.interfaces.go—RegistryClient(Push/Pull),PluginPackager.packager.go— index → per-platform manifest → config + singleplugin.tar.gzlayer;SOURCE_DATE_EPOCH-aware for reproducible digests.registry.go— ORAS push/pull + Docker credential auth.store.go— local OCI store rooted attoolhive/pluginsunderxdg.DataHome.mocks/— generated viago:generate mockgen.Testing
Exit gates covered: packager determinism under
SOURCE_DATE_EPOCH, config round-trip (incl. zero-component nil regression guard), store put/get. Plus end-to-endpackage → push → pull → extractintegration tests mirroringoci/skills.task lint→ 0 issuestask test(race) → all passtask license-check→ cleanReview
A multi-axis review panel (spec / standards / architecture / security / devex / duplication) ran on the diff. No ship-blockers. In-scope findings applied:
componentInventorynow returnsnilwhen empty (round-trip fix), a dedicated config round-trip test, andintegration_test.go. Deferred (parity withoci/skills, separate follow-ups): extracting the byte-identicalStore/Registryintooci/artifact, andPullSSRF/PlainHTTPhardening.Next gate
Precedes GATE-C2: tag
toolhive-coreafter merge, then bumptoolhive/go.mod. Nothing in Phase 2 (stacklok/toolhive#5526) compiles before that tag.Part of stacklok/toolhive#5525
Closes #131
🤖 Generated with Claude Code