From fb747989d0e08ac8db2c7b61e7469cd4120a2f6f Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Tue, 20 Oct 2026 11:16:27 +0200 Subject: [PATCH] Add MacOS sandboxing to isolate script --- isolate | 180 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 157 insertions(+), 23 deletions(-) diff --git a/isolate b/isolate index 2e6c2d3..f1c96b6 100755 --- a/isolate +++ b/isolate @@ -1,12 +1,57 @@ #!/bin/bash -project_dir=$( pwd -P ) || exit 1 -home_dir=$( readlink -e -- "${HOME}" ) || exit 1 +# macOS does not provide GNU readlink -e. Keep this compatible with Bash 3.2. +resolve_path() ( + local path="${1}" links=0 directory + [[ "${path}" = /* ]] || path="${PWD}/${path}" + while :; do + if [[ -d "${path}" ]]; then + cd -P -- "${path}" && pwd -P + exit "${?}" + fi + directory="${path%/*}" + cd -P -- "${directory:-/}" || exit 1 + path="${PWD%/}/${path##*/}" + if [[ ! -L "${path}" ]]; then + if [[ ! -e "${path}" ]]; then + echo "Cannot resolve ${1}" >&2 + exit 1 + fi + printf '%s\n' "${path}" + exit + fi + links=$(( ${links} + 1 )) + if [[ "${links}" -gt 40 ]]; then + echo "Too many symlinks in ${1}" >&2 + exit 1 + fi + path=$( readlink "${path}" ) || exit 1 + [[ "${path}" = /* ]] || path="${PWD}/${path}" + done +) -if ! bubblewrap=$( command -v bwrap ); then - echo "bubblewrap (bwrap) is required" >&2 - exit 1 -fi +platform=$( uname -s ) || exit 1 +case "${platform}" in + Linux) + if ! bubblewrap=$( command -v bwrap ); then + echo "bubblewrap (bwrap) is required" >&2 + exit 1 + fi + ;; + Darwin) + if ! sandbox_exec=$( command -v sandbox-exec ); then + echo "sandbox-exec is required on macOS" >&2 + exit 1 + fi + ;; + *) + echo "Unsupported platform: ${platform}" >&2 + exit 1 + ;; +esac + +project_dir=$( pwd -P ) || exit 1 +home_dir=$( resolve_path "${HOME}" ) || exit 1 agents=( $( command -v claude ) $( command -v codex ) @@ -39,19 +84,34 @@ declare -a rw_binds declare -a bwrap_args declare -a bind_sources declare -a bind_destinations +declare -a hidden_paths -ro_binds=( - /usr - /lib - /lib64 - /bin - /etc/resolv.conf - /etc/hosts - /etc/ssl - /etc/passwd - /etc/group - "${HOME}/sources" -) +if [[ "${platform}" = Linux ]]; then + ro_binds=( + /usr + /lib + /lib64 + /bin + /etc/resolv.conf + /etc/hosts + /etc/ssl + /etc/passwd + /etc/group + "${HOME}/sources" + ) +else + # System libraries, certificates, and Intel/Apple Silicon Homebrew installs. + # Do not allow all of /System: /System/Volumes/Data also exposes user files. + for readonly_path in /System/Library /System/Volumes/Preboot/Cryptexes /System/Cryptexes \ + /usr /bin /sbin /opt/homebrew \ + /Library/Apple /Library/Developer /Library/Frameworks /Library/Keychains \ + /Library/Preferences /private/var/db/dyld /private/var/db/timezone \ + /etc/resolv.conf /etc/hosts /etc/ssl /etc/passwd /etc/group "${HOME}/sources"; do + if [[ -e "${readonly_path}" ]]; then + ro_binds+=("${readonly_path}") + fi + done +fi for readonly_path in "${HOME}/.gitconfig" "${HOME}/bin" "${HOME}/.local" "${HOME}/.atuin" "${XDG_RUNTIME_DIR}"; do if [[ -e "${readonly_path}" ]]; then @@ -59,7 +119,7 @@ for readonly_path in "${HOME}/.gitconfig" "${HOME}/bin" "${HOME}/.local" "${HOME fi done -for readwrite_path in "${HOME}/.claude" "${HOME}/.claude.json" "${HOME}/.local/share/claude" "${HOME}/.pi" "${project_dir}"; do +for readwrite_path in /tmp "${HOME}/.claude" "${HOME}/.claude.json" "${HOME}/.local/share/claude" "${HOME}/.pi" "${project_dir}"; do if [[ -e "${readwrite_path}" ]]; then rw_binds+=("${readwrite_path}") fi @@ -67,13 +127,13 @@ done # Resolve mount sources, but keep the paths applications expect as destinations. for readonly_path in "${ro_binds[@]}"; do - bind_source=$( readlink -e -- "${readonly_path}" ) || exit 1 + bind_source=$( resolve_path "${readonly_path}" ) || exit 1 bwrap_args+=("--ro-bind" "${bind_source}" "${readonly_path}") bind_sources+=("${bind_source}") bind_destinations+=("${readonly_path}") done for readwrite_path in "${rw_binds[@]}"; do - bind_source=$( readlink -e -- "${readwrite_path}" ) || exit 1 + bind_source=$( resolve_path "${readwrite_path}" ) || exit 1 bwrap_args+=("--bind" "${bind_source}" "${readwrite_path}") bind_sources+=("${bind_source}") bind_destinations+=("${readwrite_path}") @@ -90,7 +150,8 @@ fi # Mask secrets after all directory mounts so later binds cannot expose them. for hide_path in "${git_config}" "${git_worktree_config}" "${project_dir}/.env"; do if [[ -e "${hide_path}" ]]; then - hide_path=$( readlink -e -- "${hide_path}" ) || exit 1 + hide_path=$( resolve_path "${hide_path}" ) || exit 1 + hidden_paths+=("${hide_path}") bwrap_args+=("--ro-bind" /dev/null "${hide_path}") # A symlinked mount can expose the same file at another destination. for i in "${!bind_sources[@]}"; do @@ -104,9 +165,82 @@ for hide_path in "${git_config}" "${git_worktree_config}" "${project_dir}/.env"; fi done +if [[ "${platform}" = Darwin ]]; then + # Seatbelt restricts the host filesystem; it does not provide Linux namespaces. + # Keep path values out of the profile source by using sandbox-exec parameters. + declare -a sandbox_parameters + declare -a macos_write_paths + macos_profile='(version 1) +(deny default) +(allow process-exec process-fork) +(allow process-info* (target same-sandbox)) +(allow signal (target same-sandbox)) +(allow sysctl-read) +(allow network*) +(allow mach-lookup + (global-name "com.apple.system.logger") + (global-name "com.apple.logd") + (global-name "com.apple.system.notification_center") + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.SystemConfiguration.configd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.securityd.xpc")) +(allow file-read-metadata) +; The dynamic loader needs access to the root directory, not its contents recursively. +(allow file-read* (literal "/")) +(allow file-read* (literal "/dev/random") (literal "/dev/urandom") (literal "/dev/zero")) +(allow file-read* file-write* file-ioctl + (literal "/dev/null") (literal "/dev/tty") (literal "/dev/ptmx") + (subpath "/dev/fd") (regex #"^/dev/ttys[0-9]+$")) +(allow pseudo-tty)' + + add_macos_rule() { + local action="${1}" operations="${2}" path="${3}" filter="${4:-literal}" + local parameter="PATH_${#sandbox_parameters[@]}" + if [[ -z "${4}" && -d "${path}" ]]; then + filter=subpath + fi + sandbox_parameters+=("-D" "${parameter}=${path}") + macos_profile+=$'\n'"(${action} ${operations} (${filter} (param \"${parameter}\")))" + } + + for readonly_path in "${ro_binds[@]}"; do + readonly_path=$( resolve_path "${readonly_path}" ) || exit 1 + add_macos_rule allow 'file-read*' "${readonly_path}" + done + for readwrite_path in "${rw_binds[@]}"; do + readwrite_path=$( resolve_path "${readwrite_path}" ) || exit 1 + add_macos_rule allow 'file-read* file-write*' "${readwrite_path}" + macos_write_paths+=("${readwrite_path}") + done + + # Keep a per-command TMPDIR in addition to access to the shared system /tmp. + sandbox_tmp=$( mktemp -d "${TMPDIR:-/tmp}/isolate.XXXXXXXX" ) || exit 1 + trap 'rm -rf -- "${sandbox_tmp}"' EXIT + sandbox_tmp=$( resolve_path "${sandbox_tmp}" ) || exit 1 + add_macos_rule allow 'file-read* file-write*' "${sandbox_tmp}" + + # Later Seatbelt rules win. Keep these denies after every file-access allow. + for hide_path in "${hidden_paths[@]}"; do + add_macos_rule deny 'file-read* file-write*' "${hide_path}" + # Do not let a writable parent (for example .git) be renamed to bypass the deny. + for readwrite_path in "${macos_write_paths[@]}"; do + parent_path="${hide_path%/*}" + while [[ "${parent_path}" = "${readwrite_path}/"* ]]; do + add_macos_rule deny 'file-write-unlink' "${parent_path}" literal + parent_path="${parent_path%/*}" + done + done + done + + TMPDIR="${sandbox_tmp}/" "${sandbox_exec}" "${sandbox_parameters[@]}" \ + -p "${macos_profile}" "${coder}" "${@}" + exit "${?}" +fi + "${bubblewrap}" \ "${bwrap_args[@]}" \ - --tmpfs /tmp \ --proc /proc \ --dev /dev \ --share-net \