Skip to content

Commit 9e64878

Browse files
JAORMXclaude
authored andcommitted
feat(dockhand): support transitive dependency overrides/constraints in spec.yaml
Renovate version bumps fail the build-containers Grype gate when the bumped package pins or caps a transitive dependency to a vulnerable version. Add an optional dependency-override mechanism to the spec.yaml schema, plumbed into the generated Dockerfile. - npx: spec.overrides ([]{package, version, reason}) is injected as an npm "overrides" block in the generated package.json before the npm install step. - uvx: spec.constraints ([]{spec, reason}) is written to a uv overrides requirements file and passed to "uv tool install --overrides". Both injection points match the install step by content (not line number) so they stay robust to toolhive template formatting. Every entry requires a non-empty reason (validation fails otherwise) so the justification for circumventing an upstream pin is auditable in-repo. Verified end-to-end against the CI build + Grype recipe: - #469 @brightdata/mcp 2.9.5 + override @modelcontextprotocol/sdk 1.26.0: resolves to SDK 1.26.0, grype --fail-on high --only-fixed passes. - #527 mcp-clickhouse 0.3.0 + constraint fastmcp>=3.2.0: fastmcp 3.4.0, import mcp_clickhouse OK, grype passes. Refs #668 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 21e32fe commit 9e64878

3 files changed

Lines changed: 546 additions & 1 deletion

File tree

cmd/dockhand/main.go

Lines changed: 230 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33

44
import (
55
"context"
6+
"encoding/json"
67
"fmt"
78
"log/slog"
89
"os"
@@ -22,6 +23,17 @@ import (
2223
skillpkg "github.com/stacklok/dockyard/internal/skills"
2324
)
2425

26+
// Supported package protocols.
27+
const (
28+
protocolNpx = "npx"
29+
protocolUvx = "uvx"
30+
protocolGo = "go"
31+
32+
// mcpContainerVersion is the placeholder version toolhive's npx template stamps into
33+
// the generated package.json; we reuse it when re-emitting that file with overrides.
34+
mcpContainerVersion = "1.0.0"
35+
)
36+
2537
// MCPServerSpec defines the structure of our YAML configuration files
2638
type MCPServerSpec struct {
2739
// Metadata about the MCP server
@@ -44,6 +56,33 @@ type MCPServerPackageSpec struct {
4456
Package string `yaml:"package"` // e.g., "@upstash/context7-mcp"
4557
Version string `yaml:"version,omitempty"` // e.g., "1.0.14"
4658
Args []string `yaml:"args,omitempty"` // Additional arguments for the package
59+
60+
// Overrides forces specific versions of transitive npm dependencies (npx protocol).
61+
// Each entry is injected into an "overrides" block of the generated package.json so
62+
// that npm resolves the pinned version regardless of upstream's declared range.
63+
Overrides []OverrideEntry `yaml:"overrides,omitempty"`
64+
65+
// Constraints forces specific versions of transitive Python dependencies (uvx protocol).
66+
// Each entry is written to a uv overrides requirements file and passed to
67+
// "uv tool install --overrides" so that uv resolves the pinned version even when
68+
// upstream caps the dependency.
69+
Constraints []ConstraintEntry `yaml:"constraints,omitempty"`
70+
}
71+
72+
// OverrideEntry pins a transitive npm dependency to a specific version (npx protocol).
73+
// Reason is mandatory so the justification for circumventing the upstream pin is auditable
74+
// in-repo, mirroring security.allowed_issues.
75+
type OverrideEntry struct {
76+
Package string `yaml:"package"` // e.g., "@modelcontextprotocol/sdk"
77+
Version string `yaml:"version"` // e.g., "1.26.0"
78+
Reason string `yaml:"reason"` // why this override is needed (required)
79+
}
80+
81+
// ConstraintEntry pins a transitive Python dependency via a uv override requirement
82+
// (uvx protocol). Reason is mandatory so the justification is auditable in-repo.
83+
type ConstraintEntry struct {
84+
Spec string `yaml:"spec"` // a PEP 508 requirement, e.g., "fastmcp>=3.2.0"
85+
Reason string `yaml:"reason"` // why this constraint is needed (required)
4786
}
4887

4988
// MCPServerProvenance contains supply chain provenance information
@@ -335,7 +374,7 @@ func loadMCPServerSpec(configPath string) (*MCPServerSpec, error) {
335374
}
336375

337376
// Validate protocol
338-
validProtocols := []string{"npx", "uvx", "go"}
377+
validProtocols := []string{protocolNpx, protocolUvx, protocolGo}
339378
isValid := false
340379
for _, p := range validProtocols {
341380
if spec.Metadata.Protocol == p {
@@ -347,9 +386,49 @@ func loadMCPServerSpec(configPath string) (*MCPServerSpec, error) {
347386
return nil, fmt.Errorf("invalid protocol %s, must be one of: %v", spec.Metadata.Protocol, validProtocols)
348387
}
349388

389+
// Validate dependency overrides/constraints
390+
if err := validateDependencyOverrides(&spec); err != nil {
391+
return nil, err
392+
}
393+
350394
return &spec, nil
351395
}
352396

397+
// validateDependencyOverrides validates the optional overrides (npx) and constraints
398+
// (uvx) blocks. Every entry must carry a non-empty Reason so the justification for
399+
// circumventing an upstream version pin is auditable in-repo.
400+
func validateDependencyOverrides(spec *MCPServerSpec) error {
401+
if len(spec.Spec.Overrides) > 0 && spec.Metadata.Protocol != protocolNpx {
402+
return fmt.Errorf("spec.overrides is only supported for the npx protocol, got %q", spec.Metadata.Protocol)
403+
}
404+
if len(spec.Spec.Constraints) > 0 && spec.Metadata.Protocol != protocolUvx {
405+
return fmt.Errorf("spec.constraints is only supported for the uvx protocol, got %q", spec.Metadata.Protocol)
406+
}
407+
408+
for i, o := range spec.Spec.Overrides {
409+
if o.Package == "" {
410+
return fmt.Errorf("spec.overrides[%d].package is required", i)
411+
}
412+
if o.Version == "" {
413+
return fmt.Errorf("spec.overrides[%d].version is required", i)
414+
}
415+
if strings.TrimSpace(o.Reason) == "" {
416+
return fmt.Errorf("spec.overrides[%d].reason is required (document why %s is pinned to %s)", i, o.Package, o.Version)
417+
}
418+
}
419+
420+
for i, c := range spec.Spec.Constraints {
421+
if strings.TrimSpace(c.Spec) == "" {
422+
return fmt.Errorf("spec.constraints[%d].spec is required", i)
423+
}
424+
if strings.TrimSpace(c.Reason) == "" {
425+
return fmt.Errorf("spec.constraints[%d].reason is required (document why %q is constrained)", i, c.Spec)
426+
}
427+
}
428+
429+
return nil
430+
}
431+
353432
// generateDockerfile generates a Dockerfile using toolhive's library
354433
func generateDockerfile(ctx context.Context, spec *MCPServerSpec, customTag string) (string, error) {
355434
// Create the protocol scheme string
@@ -383,9 +462,159 @@ func generateDockerfile(ctx context.Context, spec *MCPServerSpec, customTag stri
383462
return "", fmt.Errorf("failed to generate Dockerfile for protocol scheme %s: %w", protocolScheme, err)
384463
}
385464

465+
// Post-process the generated Dockerfile to inject any dependency overrides.
466+
// toolhive returns the Dockerfile as a string, which is our injection seam; toolhive
467+
// itself needs no changes.
468+
dockerfile, err = injectDependencyOverrides(dockerfile, spec)
469+
if err != nil {
470+
return "", fmt.Errorf("failed to inject dependency overrides: %w", err)
471+
}
472+
386473
return dockerfile, nil
387474
}
388475

476+
// injectDependencyOverrides rewrites the generated Dockerfile to force pinned versions
477+
// of transitive dependencies. For npx it injects an npm "overrides" block; for uvx it
478+
// adds a uv overrides requirements file to the "uv tool install" step. It matches the
479+
// relevant install step by content (not line number) so it stays robust to changes in
480+
// toolhive's template formatting.
481+
func injectDependencyOverrides(dockerfile string, spec *MCPServerSpec) (string, error) {
482+
switch spec.Metadata.Protocol {
483+
case protocolNpx:
484+
if len(spec.Spec.Overrides) == 0 {
485+
return dockerfile, nil
486+
}
487+
return injectNpmOverrides(dockerfile, spec.Spec.Overrides)
488+
case protocolUvx:
489+
if len(spec.Spec.Constraints) == 0 {
490+
return dockerfile, nil
491+
}
492+
return injectUvOverrides(dockerfile, spec.Spec.Constraints)
493+
default:
494+
return dockerfile, nil
495+
}
496+
}
497+
498+
// injectNpmOverrides rewrites the package.json creation step so the generated package.json
499+
// carries an "overrides" block. npm honors "overrides" only when present in the package.json
500+
// it installs into, so this is injected before the "npm install" step. The toolhive template
501+
// creates the package.json with a line of the form:
502+
//
503+
// RUN echo '{"name":"mcp-container","version":"1.0.0"}' > package.json
504+
//
505+
// We locate that line by content (the "> package.json" redirect) and replace the JSON payload
506+
// with one that includes the overrides.
507+
func injectNpmOverrides(dockerfile string, overrides []OverrideEntry) (string, error) {
508+
overrideMap := make(map[string]string, len(overrides))
509+
for _, o := range overrides {
510+
overrideMap[o.Package] = o.Version
511+
}
512+
513+
// Mirror the package.json name/version that toolhive's npx template emits, adding the
514+
// overrides block.
515+
pkgJSON := map[string]any{
516+
"name": "mcp-container",
517+
"version": mcpContainerVersion,
518+
"overrides": overrideMap,
519+
}
520+
pkgJSONBytes, err := json.Marshal(pkgJSON)
521+
if err != nil {
522+
return "", fmt.Errorf("failed to marshal package.json with overrides: %w", err)
523+
}
524+
525+
lines := strings.Split(dockerfile, "\n")
526+
injected := false
527+
for i, line := range lines {
528+
trimmed := strings.TrimSpace(line)
529+
// Match the package.json creation step regardless of the exact JSON payload.
530+
if strings.HasPrefix(trimmed, "RUN echo '") && strings.Contains(trimmed, "> package.json") {
531+
lines[i] = fmt.Sprintf("RUN echo '%s' > package.json", string(pkgJSONBytes))
532+
injected = true
533+
break
534+
}
535+
}
536+
537+
if !injected {
538+
return "", fmt.Errorf("could not find the 'package.json' creation step in the generated Dockerfile to inject npm overrides")
539+
}
540+
541+
return strings.Join(lines, "\n"), nil
542+
}
543+
544+
// injectUvOverrides rewrites the "uv tool install" step so it passes a uv overrides
545+
// requirements file. uv honors override requirements via "--overrides <file>", forcing the
546+
// resolved version of a transitive dependency even when upstream caps it. The toolhive
547+
// template installs with a line of the form:
548+
//
549+
// uv tool install "$package_spec" && \
550+
//
551+
// We write the override specs to a file (created via a heredoc RUN injected before the
552+
// install step) and add "--overrides" to the install invocation, matching the install line
553+
// by content rather than line number.
554+
func injectUvOverrides(dockerfile string, constraints []ConstraintEntry) (string, error) {
555+
const overridesFile = "/tmp/uv-overrides.txt"
556+
557+
// Build a RUN step that writes the overrides requirements file. Each constraint is a
558+
// PEP 508 requirement on its own line.
559+
// Emit a single logical RUN that writes each spec (one per line) to the overrides file.
560+
// Every printed line ends with a backslash continuation so the trailing redirect stays
561+
// part of the same shell command and is not parsed as a new Dockerfile instruction.
562+
var fileBuilder strings.Builder
563+
fileBuilder.WriteString("# Write uv override requirements (forces pinned transitive dependency versions)\n")
564+
fileBuilder.WriteString("RUN printf '%s\\n' \\\n")
565+
for _, c := range constraints {
566+
// Single-quote each spec for shell safety.
567+
fmt.Fprintf(&fileBuilder, " '%s' \\\n", c.Spec)
568+
}
569+
fmt.Fprintf(&fileBuilder, " > %s", overridesFile)
570+
overridesRun := fileBuilder.String()
571+
572+
lines := strings.Split(dockerfile, "\n")
573+
installIdx := -1
574+
for i, line := range lines {
575+
// Match the actual install command, not Dockerfile comments that merely mention it.
576+
// The toolhive template invokes it as: uv tool install "$package_spec"
577+
trimmed := strings.TrimSpace(line)
578+
if strings.HasPrefix(trimmed, "#") {
579+
continue
580+
}
581+
if strings.Contains(line, "uv tool install \"") {
582+
installIdx = i
583+
break
584+
}
585+
}
586+
if installIdx == -1 {
587+
return "", fmt.Errorf("could not find the 'uv tool install' step in the generated Dockerfile to inject uv overrides")
588+
}
589+
590+
// Add the --overrides flag to the install invocation.
591+
lines[installIdx] = strings.Replace(
592+
lines[installIdx],
593+
"uv tool install ",
594+
fmt.Sprintf("uv tool install --overrides %s ", overridesFile),
595+
1,
596+
)
597+
598+
// Insert the file-writing RUN step before the install step. The install step is often
599+
// preceded by comment lines and a "RUN package=..." opener; we insert immediately before
600+
// the line that opens the install RUN (the first line at or above installIdx that begins
601+
// with "RUN ").
602+
insertIdx := installIdx
603+
for j := installIdx; j >= 0; j-- {
604+
if strings.HasPrefix(strings.TrimSpace(lines[j]), "RUN ") {
605+
insertIdx = j
606+
break
607+
}
608+
}
609+
610+
out := make([]string, 0, len(lines)+1)
611+
out = append(out, lines[:insertIdx]...)
612+
out = append(out, overridesRun)
613+
out = append(out, lines[insertIdx:]...)
614+
615+
return strings.Join(out, "\n"), nil
616+
}
617+
389618
// generateImageTag creates a container image tag based on the repository structure
390619
// Following the pattern: ghcr.io/stacklok/dockyard/{protocol}/{name}:{version}
391620
func generateImageTag(spec *MCPServerSpec) string {

0 commit comments

Comments
 (0)