Skip to content

Local SOPS-Encrypted Repository Secrets and Retirement of Modal Secret Collections

Previously, repository cloning credentials (GH_REPO, GH_TOKEN, and optional DOTENV_PRIVATE_KEY) were managed as cloud-hosted Modal secret collections passed via --secret-collection (ADR 0005). This required maintaining separate Modal secret objects in the Modal cloud dashboard for each repository and coupled sandbox launching with remote secret collection provisioning. We decided to retire --secret-collection and all repository-level Modal secret collections in favor of local SOPS-encrypted Repository Fetch Secrets stored version-controlled at secrets/<secret-name>/fetch.env, selected via -s or --secret <secret-name>. Sandbox runners bake the encrypted secret file into the container image at /root/secrets/fetch.env, mount the Shared Workspace Secret share0 unconditionally across all sandboxes, and decrypt the credentials ephemerally into memory in shared/startup.sh at container boot using AGE_PRIVATE_KEY before immediately purging /root/secrets/fetch.env from disk and unsetting GH_TOKEN from memory prior to user session handoff.

Considered Options

  • Host-side SOPS decryption via modal.Secret.from_dict: Decrypt secrets/<secret-name>/fetch.env on the host workstation running sandbox/main.py using local sops decrypt, and inject the resulting environment variables as an ephemeral Modal secret. Rejected because it requires every developer workstation and CI/CD agent to have AGE_PRIVATE_KEY stored in local filesystem key files (~/.config/sops/age/keys.txt), breaking decoupled runner execution (ADR 0003).
  • Retain --secret-collection alongside --secret: Keep --secret-collection as a backwards-compatible alias that looks up Modal secret collections. Rejected because supporting two divergent secret distribution models creates operational ambiguity and undermines the unified local SOPS security architecture.
  • In-sandbox SOPS decryption with unconditional share0 mounting: Bake secrets/<secret-name>/fetch.env into the container image at /root/secrets/fetch.env, mount share0 unconditionally across all sandboxes to supply AGE_PRIVATE_KEY, strictly decrypt credentials in memory within shared/startup.sh prior to git clone, immediately remove /root/secrets/fetch.env from the filesystem, and unset GH_TOKEN before interactive shell access. Accepted because it maintains full zero-leakage distribution, requires zero host-side private keys, unifies credential management under the SOPS + Age architecture, and eliminates remote Modal secret creation friction.

Consequences

  • --secret-collection is completely removed; -s and --secret <secret-name> are the canonical CLI arguments in sandbox/main.py.
  • sandbox/main.py fails fast at argument parsing if secrets/<secret-name>/fetch.env is missing on the local filesystem.
  • sandbox/main.py bakes secrets/<secret-name>/fetch.env into /root/secrets/fetch.env for the target sandbox image and unconditionally mounts modal.Secret.from_name("share0").
  • shared/startup.sh strictly decrypts /root/secrets/fetch.env using SOPS_AGE_KEY="$AGE_PRIVATE_KEY" sops decrypt in Step 1 before git clone. If decryption fails or AGE_PRIVATE_KEY is missing, container startup terminates immediately with exit code 1.
  • shared/startup.sh purges /root/secrets/fetch.env from disk immediately after reading into memory, and unsets GH_TOKEN and temporary decryption variables from process memory before server startup and interactive session sleep.
  • All sandboxes require the centralized Modal secret share0 to exist in the Modal workspace.