Skip to content

Build System

capsem-builder is a Python CLI that reads TOML configs from guest/config/, validates them through Pydantic models, renders Jinja2 Dockerfiles, and produces per-architecture VM assets. It also generates the defaults.json consumed by the Rust binary at compile time.

flowchart TD
  subgraph Input["Source of Truth"]
    TOML["guest/config/*.toml\n(AI providers, packages,\nsecurity, VM resources)"]
  end

  subgraph Validation["Validation Layer"]
    Config["config.py\nTOML loader"]
    Models["models.py\nPydantic models\n(PackageManager, InstallConfig,\nAiProviderConfig, ...)"]
    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"]
    Defaults["config.py\ngenerate_defaults_json()"]
  end

  subgraph Output["Build Outputs"]
    Docker["Docker / Podman Build"]
    Assets["assets/{arch}/\nvmlinuz, initrd.img,\nrootfs.squashfs"]
    JSON["config/defaults.json\n(consumed by Rust)"]
    BOM["manifest.json\n+ B3SUMS"]
  end

  TOML --> Config
  Config --> Models
  Models --> Validate
  Models --> Context
  Models --> Defaults
  Context --> Jinja
  Jinja --> Docker
  Docker --> Assets
  Assets --> BOM
  Defaults --> JSON

TOML configs are the single source of truth. The data flows through four layers:

  1. TOML configs (guest/config/) — user-facing, declarative definitions for AI providers, packages, security policy, and VM resources.
  2. Pydantic models (models.py) — type-safe validation with enums (PackageManager: apt, uv, pip, npm, curl), 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. defaults.json — settings interchange consumed by Rust via include_str!, validated against settings-schema.json.
  2. Rendered Dockerfiles — Jinja2 templates (Dockerfile.rootfs.j2, Dockerfile.kernel.j2) parameterized per architecture.
  3. manifest.json — bill-of-materials with package versions, BLAKE3 hashes, and vulnerability findings.

All config lives under guest/config/. Each file maps to a Pydantic model.

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/web.tomlWebSecurityConfigDomain allow/block policyallow_read, allow_write, custom_allow, search, registry, repository
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-builder validate runs 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 Virtualization.frameworklinux/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
x86_64/
vmlinuz
initrd.img
rootfs.squashfs
tool-versions.txt
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/Podman 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 or Podman, whichever is available.
  • 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 (common with Podman’s libkrun backend on macOS).

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

ThresholdRAMNotes
Minimum4 GBBelow this, builds OOM-kill (exit 137)
Recommended8 GBComfortable margin for all installers
CI (GitHub Actions)7 GBStandard runner allocation
Terminal window
# Podman: configure VM resources
podman machine stop
podman machine set --memory 8192 --cpus 8
podman machine start
# Docker Desktop: Settings -> Resources -> Memory -> 8 GB

just doctor and capsem-builder 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-builder build --dry-run --json
# Build rootfs for arm64 only
uv run capsem-builder build --arch arm64
# Build kernel for all architectures
uv run capsem-builder build --template kernel
# Scaffold a new image config
uv run capsem-builder new my-image --from guest

The builder bridges Python config and Rust runtime through a JSON interchange layer.

flowchart LR
  TOML["guest/config/*.toml"] --> Py["generate_defaults_json()"]
  Py --> DJ["config/defaults.json"]
  DJ --> Rust["include_str! in Rust"]
  Py --> Schema["settings-schema.json"]
  Schema --> CV["Cross-language\nconformance tests"]
  DJ --> CV

generate_defaults_json() transforms a GuestImageConfig into the hierarchical JSON tree consumed by the Rust settings registry. This JSON defines every setting’s name, description, type, default value, and metadata (env vars, domain rules, UI hints).

The schema is generated from SettingsRoot.model_json_schema() (Pydantic) and written to config/settings-schema.json. Cross-language conformance tests verify that:

  1. The generated defaults.json validates against the JSON schema.
  2. Rust’s compiled-in defaults match the Python-generated output.
  3. Every setting referenced in Rust code exists in the schema.

This ensures the Python build tooling and Rust runtime never drift.