Skip to content

Build System

Capsem image builds are profile driven. capsem-admin is the enterprise-facing CLI for profile creation, validation, image planning, asset verification, and manifest generation. capsem-builder is the lower-level Python build engine it uses to validate build inputs, render Jinja2 Dockerfiles, and produce per-architecture VM assets.

The source of truth is the signed Profile V2 payload. Repo-local TOML under guest/config/ is a developer input used to generate and test built-in profiles; it is not the corporate release authority and it is not loaded by the service at runtime.

flowchart TD
  subgraph Input["Source of Truth"]
    PROFILE["Profile V2 payload\n(packages, tools, controls,\nVM assets, locks)"]
    DEV["guest/config/*.toml\n(developer input for\nbuilt-in profiles)"]
  end

  subgraph Validation["Validation Layer"]
    Admin["capsem-admin\nprofile/image commands"]
    Models["Pydantic models\n(Profile, PackageContract,\nImagePlan, Manifest)"]
    Validate["validate.py\nLinter (E001-E402, W001-W012)"]
  end

  subgraph Generation["Code Generation"]
    Context["docker.py\n_rootfs_context()\n_kernel_context()"]
    Jinja["Jinja2 Templates\nDockerfile.rootfs.j2\nDockerfile.kernel.j2"]
  end

  subgraph Output["Build Outputs"]
    Docker["Docker Build"]
    Assets["assets/{arch}/\nvmlinuz, initrd.img,\nrootfs.squashfs"]
    BOM["asset manifest\nhashes + signatures + SBOM"]
  end

  DEV --> Admin
  PROFILE --> Admin
  Admin --> Models
  Models --> Validate
  Models --> Context
  Context --> Jinja
  Jinja --> Docker
  Docker --> Assets
  Assets --> BOM

Profile payloads are the source of truth. The data flows through four layers:

  1. Profile V2 payloads — signed, typed declarations for packages, tools, controls, VM assets, and editable sections. Developer TOML can derive these profiles, but operators do not hand-edit image settings in guest/config.
  2. Pydantic models — type-safe validation with enums, frozen models, and cross-field validators.
  3. Context dicts (docker.py) — template variables assembled from the validated config. Each template type (rootfs, kernel) has its own context builder that collects packages by manager type.
  4. Jinja2 templates — Dockerfile output parameterized per architecture.

Three outputs are produced:

  1. Rendered Dockerfiles — Jinja2 templates (Dockerfile.rootfs.j2, Dockerfile.kernel.j2) parameterized per architecture.
  2. Verified VM assetsvmlinuz, initrd.img, and rootfs.squashfs with hashes/signatures recorded in the profile catalog path.
  3. SBOM / asset manifests — package versions, BLAKE3 hashes, and vulnerability findings used by release verification.

Built-in profile development still uses repo-local TOML under guest/config/. Each file maps to a Pydantic model and feeds profile/image generation. This is not an operator-facing configuration surface.

FileModelPurposeKey Fields
build.tomlBuildConfigArchitectures, compressioncompression, compression_level, architectures.*
manifest.tomlImageManifestConfigImage identity and changelogname, version, description, changelog
ai/*.tomlAiProviderConfigAI provider definitionsapi_key, network.domains, install (manager: npm/curl), cli, files
packages/apt.tomlPackageSetConfigApt package setmanager, install_cmd, packages, network
packages/python.tomlPackageSetConfigPython package setmanager, install_cmd, packages
mcp/*.tomlMcpServerConfigMCP server definitionstransport, command, url, args, env
security/*.tomlSecurity control modelsDeveloper seed inputs for built-in enforcement/detection profile packscanonical rule roots, pack ids, fixtures
vm/resources.tomlVmResourcesConfigCPU, RAM, disk limitscpu_count, ram_gb, scratch_disk_size_gb
vm/environment.tomlVmEnvironmentConfigShell, PATH, TLSshell.term, shell.home, shell.path, tls.ca_bundle
kernel/defconfig.*(raw)Kernel configs per archLinux kernel defconfig files

Example build.toml:

[build]
compression = "zstd"
compression_level = 15
[build.architectures.arm64]
base_image = "debian:bookworm-slim"
docker_platform = "linux/arm64"
rust_target = "aarch64-unknown-linux-musl"
kernel_branch = "6.6"
kernel_image = "arch/arm64/boot/Image"
defconfig = "kernel/defconfig.arm64"
node_major = 24

Example AI provider (ai/anthropic.toml):

[anthropic]
name = "Anthropic"
description = "Claude Code AI agent"
enabled = true
[anthropic.api_key]
name = "Anthropic API Key"
env_vars = ["ANTHROPIC_API_KEY"]
prefix = "sk-ant-"
docs_url = "https://console.anthropic.com/settings/keys"
[anthropic.network]
domains = ["*.anthropic.com", "*.claude.com"]
allow_get = true
allow_post = true
[anthropic.install]
manager = "curl"
packages = ["https://claude.ai/install.sh"]

capsem-admin profile validate, capsem-admin image plan, and the lower-level capsem-builder validate path run compiler-style diagnostics with error codes, severity levels, and file:line references. Errors block the build; warnings are informational.

RangeCategoryExamples
E001-E002TOML parsingMissing build.toml, invalid TOML syntax
E003-E005Pydantic validationSchema violations, empty package lists, invalid enum values
E006Domain validationURLs in domain fields, ports, path components
E008Duplicate keysSame key in multiple files within a directory
E009-E010File contentNon-absolute paths, invalid JSON in .json file settings
E100-E103Schema / JSONGenerated JSON fails schema validation
E200-E202Cross-languageRust/Python conformance mismatches
E300-E305ArtifactsMissing defconfig, CA cert, capsem-init, diagnostics
E400-E402DockerDockerfile generation failures
CodeDescription
W001Package sets configured but no registry in web security
W002Development packages (-dev, -devel) in package lists
W003Potential secrets detected in file content, headers, or env
W004Package set with no network config
W005Overlapping allow and block domain lists
W006Placeholder file content (TODO, FIXME)
W007Overly broad wildcard domains (*, *.com)
W008Duplicate env_vars across AI providers
W009Shell metacharacters in install_cmd
W010PATH missing essential directories (/usr/bin, /bin)
W011Wide-open network policy (both reads and writes, no block list)
W012Unknown Rust target (not a known musl target)

Diagnostic output format:

error: [E006] config/ai/anthropic.toml: Invalid domain pattern 'https://api.anthropic.com'
warning: [W003] config/mcp/capsem.toml: Potential secret in mcp.capsem.headers.Authorization

Two architectures are supported. Each is self-contained in build.toml and produces an independent asset directory.

ArchitectureHypervisorDocker PlatformRust TargetKernel Image
arm64Apple VZ (macOS) / KVM (Linux)linux/arm64aarch64-unknown-linux-muslarch/arm64/boot/Image
x86_64KVMlinux/amd64x86_64-unknown-linux-muslarch/x86_64/boot/bzImage

Output layout:

assets/
arm64/
vmlinuz
initrd.img
rootfs.squashfs
tool-versions.txt
image-inventory.json
x86_64/
vmlinuz
initrd.img
rootfs.squashfs
tool-versions.txt
image-inventory.json
manifest.json
B3SUMS
flowchart TD
  Load["Load TOML configs"] --> Validate["Validate (Pydantic + linter)"]
  Validate -->|errors| Abort["Abort with diagnostics"]
  Validate -->|clean| Arches["For each architecture"]
  Arches --> Cross["Cross-compile guest binaries\n(cargo build --target)"]
  Cross --> Render["Render Dockerfile.rootfs.j2"]
  Render --> Context["Assemble build context\n(CA cert, bashrc, diagnostics, binaries)"]
  Context --> Build["Docker build"]
  Build --> Export["Export container filesystem"]
  Export --> Squash["mksquashfs (zstd compression)"]
  Squash --> Versions["Extract tool versions"]
  Versions --> Checksums["Generate B3SUMS + manifest.json"]

The kernel build follows a parallel path:

flowchart TD
  KLoad["Load build.toml"] --> KResolve["Resolve kernel version\n(kernel.org LTS lookup)"]
  KResolve --> KRender["Render Dockerfile.kernel.j2"]
  KRender --> KBuild["Docker build\n(kernel compile + initrd)"]
  KBuild --> KExtract["Extract vmlinuz + initrd.img"]

Key implementation details:

  • Container runtime auto-detection. Docker CLI.
  • CI cache integration. Docker buildx with GitHub Actions cache (type=gha) when GITHUB_ACTIONS is set.
  • Kernel version resolution. Fetches the latest stable version for the configured LTS branch from kernel.org/releases.json, falls back to a hardcoded version on network failure.
  • Cross-compilation. Guest agent binaries are cross-compiled with cargo build --target {rust_target} using rust-lld as the linker (configured in .cargo/config.toml).
  • Clock skew resilience. All apt-get update calls use -o Acquire::Check-Valid-Until=false to handle container VM clock drift.

On macOS, Docker runs inside a Colima VM with limited resources. The rootfs build runs apt, npm, and curl-based CLI installers concurrently, requiring substantial memory.

ThresholdRAMNotes
Minimum12 GBTauri install-test cold build SIGTERMs below this (exit 143 mid-cargo)
Recommended16 GBComfortable margin for build-assets + install-test together
CI (GitHub Actions)7 GBStandard runner; install-test container uses pre-baked image so no cold build
Terminal window
# Colima (macOS): configure VM resources
colima stop
colima start --vm-type vz --vz-rosetta --memory 16 --cpu 8
# Linux: Docker runs natively, no memory tuning needed
# sudo apt install docker.io

just doctor and capsem-admin doctor both check these resources automatically and fail if below minimum.

AI providers declare how their CLI gets installed via [provider.install]. The builder supports multiple install strategies:

ManagerTemplate HandlingUse CaseExample
npmBatched into single npm install -g --prefixNode.js CLI toolsGemini CLI, Codex
curlEach URL gets its own RUN curl -fsSL URL | bashNative binary installersClaude Code
aptPackage set (not per-provider)System packagescoreutils, git, curl
uvPackage set (not per-provider)Python packagesnumpy, pytest
pipPackage set (not per-provider)Python packages (fallback)

At runtime, /root is a tmpfs overlay — anything baked into the rootfs under /root/ during the Docker build is hidden. This matters for CLI installers that put binaries in ~/.local/bin/ or ~/.claude/bin/:

# The installer puts claude at ~/.local/bin/claude, which is /root/.local/bin/
# inside the container. Since /root is tmpfs at runtime, copy to /usr/local/bin.
RUN curl -fsSL https://claude.ai/install.sh | bash && \
for bin in /root/.local/bin/*; do \
[ -f "$bin" ] && install -m 555 "$bin" /usr/local/bin/; \
done

The install -m 555 enforces the guest binary security invariant: all binaries are read-only, non-writable by the guest.

To add a new manager type (e.g., cargo):

  1. Add the enum value to PackageManager in models.py
  2. Collect packages in _rootfs_context() in docker.py — create a new list variable
  3. Pass it to the template context dict
  4. Add a Jinja2 block in Dockerfile.rootfs.j2
  5. Add to _INSTALL_CMDS in scaffold.py
  6. Update tests in test_docker.py and test_cli.py

The generated Dockerfile.rootfs.j2 follows a specific ordering. Understanding this is important when adding new install steps — the /root cleanup and binary permissions are load-bearing:

flowchart TD
  A["1. apt packages\n(system tools, runtimes)"] --> B["2. Node.js via nvm\n(for npm-based CLIs)"]
  B --> C["3. uv installer\n(Python package manager)"]
  C --> D["4. npm install\n(Gemini CLI, Codex)"]
  D --> E["5. CA certificate\n+ certifi patch"]
  E --> F["6. Guest binaries\n(COPY + chmod 555)"]
  F --> G["7. Shell config + diagnostics\n(bashrc, banner, tests)"]
  G --> H["8. Python packages\n(uv pip install)"]
  H --> I["9. Security hardening\n(strip setuid, rm EXTERNALLY-MANAGED)"]
  I --> J["10. rm -rf /root\n(clean HOME for tmpfs)"]
  J --> K["11. curl installers\n(Claude Code, copy to /usr/local/bin)"]
  K --> L["12. Switch apt to HTTPS"]

  style J fill:#f9f,stroke:#333
  style K fill:#bbf,stroke:#333

Step 10 and 11 ordering matters: curl installers run after the /root cleanup so there’s a clean HOME. Binaries are immediately copied to /usr/local/bin/ since /root becomes tmpfs at boot.

Every build produces manifest.json at the asset root. The BOM records:

SectionSourceContents
Packages (dpkg)dpkg-query outputName, version, architecture
Packages (pip)pip list --format jsonName, version
Packages (npm)npm ls --json --globalName, version
Assetsb3sum outputFilename, BLAKE3 hash, size in bytes
VulnerabilitiesTrivy or Grype scanCVE ID, severity, package, installed/fixed versions

The audit subcommand parses vulnerability scanner output and fails on CRITICAL or HIGH findings.

CommandDescriptionKey Options
buildRender Dockerfiles or build images--arch, --dry-run, --json, --template, --output, --kernel-version
validateLint and validate guest config--artifacts (check built artifacts too)
inspectShow config summary--json
auditParse vulnerability scan results--scanner (trivy/grype), --input, --json
initScaffold a minimal guest config directory--force
newCreate a new image config from a base--from, --non-interactive, --force
add ai-providerAdd an AI provider template--dir, --force
add packagesAdd a package set template--dir, --manager, --force
add mcpAdd an MCP server template--dir, --transport, --force
mcpStart MCP stdio server for builder tools(none)
doctorCheck build prerequisites(none)

Usage:

Terminal window
# Validate config
uv run capsem-builder validate guest
# Dry-run: render Dockerfiles without building
uv run capsem-admin image build config/profiles/base/coding.profile.toml --dry-run --json
# Build rootfs for arm64 only
uv run capsem-admin image build config/profiles/base/coding.profile.toml --arch arm64
# Build kernel for all architectures
uv run capsem-admin image build config/profiles/base/coding.profile.toml --template kernel
# Scaffold a new image config
uv run capsem-builder new my-image --from guest

The builder/admin tooling publishes schema artifacts for validation and docs. Those artifacts are not runtime defaults authority.

flowchart LR
  Profile["Profile Pydantic models"] --> PS["capsem.profile.v2 schema"]
  Settings["Service settings Pydantic model"] --> SS["capsem.service-settings.v2 schema"]
  Descriptor["Guest/UI descriptor Pydantic models"] --> DS["settings-schema.json"]
  PS --> CV["Cross-language\nconformance tests"]
  SS --> CV
  DS --> CV

capsem-admin validates profile and service-settings JSON/TOML through Pydantic first, then emits structured JSON reports. Rust validates the same closed contracts at runtime. The guest/UI descriptor schema describes renderable settings nodes for the UI and tests.

Cross-language conformance tests verify that:

  1. Python and Rust agree on Service Settings V2 defaults and invalid shapes.
  2. Profile payload fixtures round-trip through the typed model and reject unknown fields.
  3. UI descriptor fixtures remain parseable in Python, Rust, and TypeScript.

This keeps Python tooling, Rust runtime contracts, and frontend rendering in lockstep without reviving generated runtime defaults.