feat(claude): host-side marketplace cloning for private plugin repos#240
Conversation
dpup
left a comment
There was a problem hiding this comment.
The design is sound and solves the real problem (private repos need host credentials, not build-time credentials). But there's one P1 that blocks merge and several P2s worth fixing.
🔴 P1 — Ref field is silently ignored (blocks merge)
config.MarketplaceSpec has a Ref string field with a config test that asserts it round-trips. CloneMarketplace accepts a ref parameter and passes it as --branch. But manager.go:1258 hardcodes it to "":
clonedDir, cloneErr := claude.CloneMarketplace(ctx, m.Repo, "")A user who sets ref: v2.0 in moat.yaml silently gets the default branch. Additionally, Ref isn't included in the image hash, so changing the ref wouldn't even trigger a rebuild.
Either wire it through (propagate Ref from config → MarketplaceConfig → CloneMarketplace, and include it in ImageTag), or remove the field from the schema until it's implemented. The current state is a documented API that does nothing.
🟡 P2 — Should fix before merge
time.Now() in GenerateKnownMarketplaces (marketplace.go:128)
Every call produces a different known-marketplaces.json, invalidating Docker's layer cache on every build even when nothing changed. The rest of the system is carefully content-addressed — this breaks that invariant. Use a fixed constant ("1970-01-01T00:00:00Z") or the repo's last commit timestamp.
Clone loop runs before ImageExists check (manager.go:1256 vs 1359)
The clone + collect loop runs unconditionally. If the image is already cached (the common case), all marketplace repos are cloned and all files are read into memory for nothing. Gate the clone loop on !exists, consistent with how the build step itself is gated.
ExtraContextFiles inside if ScriptName != "" (deps/dockerfile.go:214–219)
known-marketplaces.json is only merged into the build context inside the ScriptName guard. Today this always holds (pre-cloned marketplaces imply plugins imply a script), but the coupling is implicit. If a future caller sets ExtraContextFiles without a script, the files are silently dropped while the Dockerfile still has COPY directives referencing them — a confusing build failure. Move the ExtraContextFiles merge outside the condition.
Validate marketplace names in manager.go before CollectMarketplaceFiles (manager.go:~1262)
The name from ExtraKnownMarketplaces is used to construct build-context map keys ("marketplaces/" + name + "/") before validMarketplaceName is checked. Validation happens later in GenerateDockerfileSnippet. Check validMarketplaceName at the top of the loop in manager.go and skip early on invalid names — don't let an invalid name influence the build-context map at all.
Add --no-recurse-submodules (marketplace.go:86)
git clone --depth 1 doesn't prevent submodule initialization. A malicious marketplace with submodules could trigger hooks on the host. Add --no-recurse-submodules to the clone args.
🔵 P3 — Nice to have
validPreClonedPathallows..— the regex^[a-zA-Z0-9._/-]+$permits traversal sequences. Not exploitable today (PreClonedis always"marketplaces/" + validatedName), but misleadingly permissive. Consider^[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*$or an explicitstrings.Contains(m.PreCloned, "..")guard.fmt.Sprintfwith no format args atdockerfile.go:138— plain string literal suffices.refparameter — if not wiring it up now, remove it fromCloneMarketplace's signature until there's a concrete use case with a config field behind it.- File size guard in
CollectMarketplaceFiles— no bound on per-file or aggregate size. A large binary in a marketplace repo loads entirely into memory. Worth a size check with alog.Warnand skip.
f43bc72 to
6e54128
Compare
|
@dpup I think I addressed all comments, can you look again pls? Regarding the Summary of changes by Claude Code: Performance
Security & safety
Correctness
|
Clone marketplace repos on the host where git credentials exist, collect files for Docker build context, and generate known_marketplaces.json for pre-cloned marketplaces.
When a marketplace has PreCloned set, COPY its files into the image and write known_marketplaces.json instead of running 'claude plugin marketplace add'. This enables private marketplace repos without leaking credentials into the build context.
Marketplace repos are now cloned on the host machine where git credentials are available, then copied into the Docker build context. This enables private marketplace repos without exposing credentials in the container image. Falls back to build-time cloning if host clone fails.
The Ref field was defined in the config schema and documented but never wired through to CloneMarketplace — users who set ref in moat.yaml silently got the default branch. Remove it entirely until there's a concrete use case with full wiring.
Prevents submodule initialization during host-side clone, which could trigger hooks from a malicious marketplace repo.
…_marketplaces.json time.Now() produced a different known-marketplaces.json on every build, invalidating Docker layer cache even when nothing changed. Use the last commit timestamp from the cloned repo instead — deterministic for the same repo state, changes only when the repo is actually updated.
A marketplace repo with large binaries could load entirely into memory. Skip files over 10MB to bound memory usage during host-side cloning.
Move name validation to the top of the clone loop in manager.go so invalid names are rejected before they influence build-context map keys. Export ValidMarketplaceName for use outside the claude package.
ExtraContextFiles were only merged into the build context when a plugin script was generated. If a future caller sets ExtraContextFiles without a script, the files would be silently dropped while Dockerfile COPY directives still reference them. Decouple the two.
Add explicit strings.Contains(path, "..") check before the regex validation. The regex already disallows most dangerous characters but misleadingly permits ".." sequences.
The clone + collect loop ran unconditionally before the ImageExists check. For cached images (the common case), all marketplace repos were cloned and read into memory for nothing. Defer cloning to the build path so cached runs skip it entirely.
10MB was too generous for plugin marketplace files which are mostly small JSON and scripts. Also log a warning when files are skipped instead of silently ignoring them.
Previously the error was silently swallowed. Even though the failure is theoretically impossible (json.MarshalIndent on simple strings), silent errors make debugging harder.
Use "1970-01-01T00:00:00Z" instead of "+00:00" offset format to match RFC 3339 / Go time.RFC3339 convention.
CloneMarketplace already guarantees commitTime is non-empty (falls back to epoch when git log fails), and failed clones skip the marketplace entirely. The empty-string check in GenerateKnownMarketplaces was dead code.
- Remove stale `ref: main` from marketplace example in Claude Code guide - Update guide text to describe host-side cloning with local git credentials - Replace script echoes with log.Warn for pre-cloned path validation - Add test for path traversal rejection in pre-cloned marketplace paths
e1b510c to
515b972
Compare
|
There was a conflict with the main branch, so I had to rebase the PR. Hopefully that does not make the review too complicated. This is the first commit you have not reviewed yet: 8e4b254 |
dpup
left a comment
There was a problem hiding this comment.
Review comments on the host-side marketplace cloning logic.
| } | ||
|
|
||
| m.PreCloned = "marketplaces/" + m.Name | ||
| m.CommitTime = commitTime |
There was a problem hiding this comment.
If CollectMarketplaceFiles succeeds but the repo is empty (0 files), contextFiles stays nil/empty but PreCloned is still set here. Later, the len(marketplaceContextFiles) > 0 guard at line 1430 skips the Dockerfile regeneration — so the COPY directives never appear. But PreCloned is set, so GenerateDockerfileSnippet filters this marketplace into the preCloned slice and skips the marketplace add command for it. Net effect: the marketplace is silently skipped entirely.
Consider guarding this assignment on len(files) > 0, or treating an empty clone as a failure that falls back to build-time cloning.
| // git credentials (gh auth, SSH keys, credential helpers) handle authentication. | ||
| // Returns cleanup directories and context files for the build. | ||
| cloneMarketplacesOnHost := func() (cleanups []string, contextFiles map[string][]byte) { | ||
| for i := range claudeMarketplaces { |
There was a problem hiding this comment.
This closure mutates claudeMarketplaces elements via pointer (m.PreCloned = ..., m.CommitTime = ...) as a side effect, but the signature only advertises returning cleanups and contextFiles. The hidden mutation is what makes the second GenerateDockerfile call produce different output — it only works because the caller knows to re-call GenerateDockerfile after this runs.
Consider either documenting the mutation in the comment/signature (e.g. returning the modified configs) or restructuring so the data flow is explicit.
| marketplaceCleanups, marketplaceContextFiles := cloneMarketplacesOnHost() | ||
| defer func() { | ||
| for _, dir := range marketplaceCleanups { | ||
| os.RemoveAll(dir) |
There was a problem hiding this comment.
This call site is ~120 lines from the closure definition at line 1300. The distance makes it hard to follow the data flow, especially since the closure mutates claudeMarketplaces as a side effect that the code here depends on (the second GenerateDockerfile call). Consider making cloneMarketplacesOnHost a standalone function, or moving the definition closer to this call site.
|
I found one more issue with registering marketplace when building a fresh image, I will try to add a fix for it. It seems to not work only with Apple containers |
863f899 to
a461f07
Compare
93623c4 to
ff2ddee
Compare
|
Thanks for the contribution! This is shipping in v0.5.0. |
Summary
known_marketplaces.jsondirectlyclaude plugin installfinds plugins from the local filesystem — no network needed during buildclaude plugin marketplace addif host clone failsThis enables private plugin marketplaces (e.g., company-internal GitHub repos) to work in moat containers without leaking credentials into the Docker image.
Changes
internal/providers/claude/marketplace.go—CloneMarketplace,CollectMarketplaceFiles,GenerateKnownMarketplacesinternal/providers/claude/dockerfile.go— HandlePreClonedmarketplaces with COPY + known_marketplaces.jsoninternal/deps/dockerfile.go— Pass throughExtraContextFilesto build contextinternal/run/manager.go— Clone marketplaces before image build, merge into contextTest plan
go test ./internal/providers/claude/ -vgo test ./internal/deps/ -v