├── darwinjail ├── __init__.py ├── __main__.py ├── mkjail.files └── main.py ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── publish-images.yml ├── pyproject.toml ├── LICENSE └── README.md /darwinjail/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import main 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | /.idea/ 3 | /build/ 4 | /darwinjail/__pycache__/ 5 | -------------------------------------------------------------------------------- /darwinjail/__main__.py: -------------------------------------------------------------------------------- 1 | # This file exists to make `python3 -m darwinjail` work 2 | 3 | from .main import main 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "pip" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "daily" 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "darwin-jail" 3 | version = "0.0.1" 4 | description = "Chroot jail for Darwin" 5 | requires-python = ">= 3.8" 6 | license = { file = "LICENSE" } 7 | readme = "README.md" 8 | authors = [ 9 | { name = "Marat Radchenko", email = "marat@slonopotamus.org" }, 10 | ] 11 | dependencies = [ 12 | "packaging" 13 | ] 14 | 15 | [build-system] 16 | requires = [ 17 | "setuptools >= 62", 18 | "wheel", 19 | ] 20 | build-backend = "setuptools.build_meta" 21 | 22 | [project.scripts] 23 | darwin-jail = "darwinjail:main" 24 | 25 | [tool.setuptools.package-data] 26 | "*" = ["mkjail.files"] 27 | -------------------------------------------------------------------------------- /darwinjail/mkjail.files: -------------------------------------------------------------------------------- 1 | # devices 2 | /dev/null 3 | /dev/ptmx 4 | /dev/random 5 | /dev/urandom 6 | /dev/zero 7 | 8 | # Monterey+ dyld 9 | /System/Library/dyld/ 10 | 11 | # Ventura+ dyld 12 | /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_* /System/Library/dyld/ 13 | 14 | # binaries 15 | /bin/ 16 | /sbin/ 17 | /System/Library/Frameworks/ 18 | /System/Library/Perl/ 19 | /usr/bin/ 20 | /usr/lib/ 21 | /usr/libexec/ 22 | /usr/sbin/ 23 | 24 | # configs 25 | /etc/pam.d/ 26 | /etc/ssl/ 27 | /etc/sudoers 28 | /System/Library/CoreServices/SystemVersion.plist 29 | /System/Library/CoreServices/SystemVersionCompat.plist 30 | /usr/share/ 31 | /var/db/timezone/ 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [ push, pull_request, workflow_dispatch ] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v6 8 | - uses: psf/black@stable 9 | test: 10 | strategy: 11 | matrix: 12 | include: 13 | - runner: "macos-13" # Ventura Intel 14 | - runner: "macos-14" # Sequoia ARM 15 | - runner: "macos-15" # Sonoma ARM 16 | runs-on: ${{ matrix.runner }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v6 20 | - name: Setup Python 21 | uses: actions/setup-python@v6 22 | - run: pip3 install --break-system-packages . 23 | - name: Create jail 24 | run: sudo python3 -m darwinjail jail 25 | -------------------------------------------------------------------------------- /.github/workflows/publish-images.yml: -------------------------------------------------------------------------------- 1 | name: Publish images 2 | on: workflow_dispatch 3 | permissions: 4 | packages: write 5 | jobs: 6 | publish: 7 | strategy: 8 | matrix: 9 | include: 10 | - runner: "macos-13" # Ventura Intel 11 | name: ventura-i386 12 | - runner: "flyci-macos-large-latest-m1" # Ventura ARM 13 | name: ventura-arm64 14 | - runner: "macos-14" # Sonoma ARM 15 | name: sonoma-arm64 16 | runs-on: ${{ matrix.runner }} 17 | steps: 18 | - uses: actions/checkout@v6 19 | - uses: actions/setup-python@v6 20 | - run: pip3 install --break-system-packages . 21 | - run: brew install crane 22 | - run: sudo python3 -m darwinjail jail_dir 23 | - run: sudo crane auth login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" 24 | - run: sudo bash -c 'crane append --oci-empty-base -t "ghcr.io/darwin-containers/darwin-jail/${{ matrix.name }}:latest" -f <(tar -f - -c -C jail_dir .)' 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Marat Radchenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # darwin-jail 2 | 3 | [![Build Status](https://github.com/darwin-containers/darwin-jail/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/darwin-containers/darwin-jail/actions?query=branch:main) 4 | 5 | ## Prerequisites 6 | 7 | * macOS 12 Monterey or newer 8 | * Disable [System Integrity Protection](https://developer.apple.com/documentation/security/disabling_and_enabling_system_integrity_protection). 9 | SIP [doesn't allow](https://github.com/containerd/containerd/discussions/5525#discussioncomment-2685649) to `chroot` (not needed for building though). 10 | 11 | ## Usage 12 | 13 | ```shell 14 | cd "$repo_root" 15 | sudo python3 -m darwinjail "$jail_dir" # prepare chroot dir contents 16 | sudo chroot "$jail_dir" # enter chroot 17 | ``` 18 | 19 | In order to make DNS work in chroot, run: 20 | 21 | ```shell 22 | sudo mkdir -p "$jail_dir/var/run" 23 | sudo link -f /var/run/mDNSResponder "$jail_dir/var/run/mDNSResponder" 24 | ``` 25 | 26 | 46 | 47 | ## Uploading Darwin rootfs as Docker image (without Docker) 48 | 49 | ```shell 50 | brew install crane 51 | 52 | # You might first need to authenticate using 53 | # sudo crane auth login "$registry" -u "$username" -p "$password" 54 | 55 | sudo bash -c 'crane append --oci-empty-base -t "$image_tag" -f <(tar -f - -c -C "$jail_dir" .)' 56 | ``` 57 | 58 | If you want to run Darwin image using containerd or Docker, see [instructions](https://github.com/darwin-containers/homebrew-formula#darwin-native-containers). 59 | -------------------------------------------------------------------------------- /darwinjail/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import glob 5 | import os 6 | import platform 7 | import stat 8 | import subprocess 9 | from dataclasses import dataclass 10 | from packaging.version import Version 11 | 12 | 13 | @dataclass 14 | class CopyOpts: 15 | target: str 16 | allow_absent: bool = False 17 | 18 | 19 | CONST_COMMENT_INSTRUCTION: str = "#" 20 | CONST_INCLUDE_INSTRUCTION: str = "@include" 21 | MAC_VER = Version(platform.mac_ver()[0]) 22 | VENTURA_VER = Version("13") 23 | 24 | 25 | def build_queue(input_files: list[str]) -> dict[str, CopyOpts]: 26 | files = list(input_files) 27 | queue: dict[str, CopyOpts] = dict() 28 | visited: set[str] = set() 29 | 30 | while len(files) > 0: 31 | file = os.path.abspath(files.pop()) 32 | 33 | if file in visited: 34 | raise AssertionError(f"same input file specified multiple times: {file}") 35 | visited.add(file) 36 | 37 | with open(file) as f: 38 | for line in f.read().splitlines(): 39 | line = line.strip() 40 | if len(line) <= 0: 41 | continue 42 | 43 | if line.startswith(CONST_COMMENT_INSTRUCTION): 44 | continue 45 | 46 | if line.startswith(CONST_INCLUDE_INSTRUCTION): 47 | include_file = line[len(CONST_INCLUDE_INSTRUCTION) :].strip() 48 | files.append(os.path.join(os.path.dirname(file), include_file)) 49 | continue 50 | 51 | parts = line.split(" ") 52 | 53 | srcs = parts[0] 54 | for src in glob.glob(srcs, include_hidden=True): 55 | if len(parts) == 1: 56 | dst = parts[0] 57 | elif len(parts) == 2: 58 | dst = parts[1] 59 | else: 60 | raise AssertionError() 61 | queue[src] = CopyOpts(target=dst) 62 | 63 | return queue 64 | 65 | 66 | def copy_files(target_dir: str, queue: dict[str, CopyOpts]) -> None: 67 | visited: set[str] = set() 68 | 69 | while len(queue) > 0: 70 | source_path, copy_opts = queue.popitem() 71 | visited.add(source_path) 72 | 73 | try: 74 | st = os.lstat(source_path) 75 | except FileNotFoundError: 76 | if copy_opts.allow_absent: 77 | # This happens due to dynamic linker cache on Big Sur and later 78 | continue 79 | else: 80 | raise 81 | 82 | full_target_path = target_dir + copy_opts.target 83 | 84 | # TODO: Preserve dir permissions 85 | os.makedirs(os.path.dirname(full_target_path), exist_ok=True) 86 | 87 | subprocess.check_call(["rsync", "-ah", source_path, full_target_path]) 88 | 89 | if MAC_VER < VENTURA_VER and stat.S_ISREG(st.st_mode): 90 | otool_output = "" 91 | try: 92 | otool_output = ( 93 | subprocess.check_output( 94 | ["/usr/bin/otool", "-L", source_path], stderr=subprocess.STDOUT 95 | ) 96 | .decode("UTF-8") 97 | .split("\n") 98 | ) 99 | except subprocess.CalledProcessError as err: 100 | # Treat this error as warning (do not abort on macOS Monterey) 101 | otool_error_datalayout = ( 102 | "LLVM ERROR: Sized aggregate specification in datalayout string" 103 | ) 104 | if err.stdout.decode("UTF-8").startswith(otool_error_datalayout): 105 | print(otool_error_datalayout) 106 | print("for command:") 107 | print(err.args) 108 | else: 109 | raise err 110 | 111 | for line in otool_output: 112 | if not line.startswith("\t"): 113 | continue 114 | 115 | dependency = line.lstrip().split("(", 1)[0].rstrip() 116 | if dependency in visited: 117 | continue 118 | 119 | queue[dependency] = CopyOpts(target=dependency, allow_absent=True) 120 | elif stat.S_ISDIR(st.st_mode): 121 | for d, _, files in os.walk(source_path): 122 | for f in files: 123 | f_path = os.path.join(d, f) 124 | if f_path not in visited and f_path not in queue: 125 | queue[f_path] = CopyOpts(target=f_path) 126 | 127 | 128 | def main(): 129 | parser = argparse.ArgumentParser() 130 | parser.add_argument( 131 | "jail_dir", 132 | metavar="JAIL_DIR", 133 | help="Path to directory where jail chroot will be created", 134 | ) 135 | parser.add_argument( 136 | "-f", 137 | "--files", 138 | default=[], 139 | action="append", 140 | metavar="JAIL_FILES", 141 | help="Jail file(s) to use", 142 | ) 143 | args = parser.parse_args() 144 | 145 | files = args.files 146 | if len(files) <= 0: 147 | script_dir = os.path.abspath(os.path.dirname(__file__)) 148 | files = [os.path.join(script_dir, "mkjail.files")] 149 | 150 | queue = build_queue(files) 151 | 152 | copy_files(args.jail_dir, queue) 153 | 154 | # I'm not sure what this file does 155 | chroot_marker = os.path.join(args.jail_dir, "AppleInternal", "XBS", ".isChrooted") 156 | os.makedirs(os.path.dirname(chroot_marker), exist_ok=True) 157 | open(chroot_marker, "a").close() 158 | 159 | # TODO: We do not want this to appear in docker image. Make separate build/run commands? 160 | # mDNSResponder = os.path.join(jail_dir, "var", "run", "mDNSResponder") 161 | # os.makedirs(os.path.dirname(mDNSResponder), exist_ok=True) 162 | # # This makes DNS work inside chroot 163 | # os.link("/var/run/mDNSResponder", mDNSResponder) 164 | --------------------------------------------------------------------------------