#!/bin/bash # 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 ) 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 ) $( command -v pi ) $( command -v ps ) # for testing $( command -v ls ) # for testing $( command -v touch ) # for testing $( command -v bash ) # for testing ) if [[ "${project_dir}" = "${home_dir}" ]]; then echo "Not running in ${HOME}" >&2 exit 1 fi if [[ -n "${1}" ]] && [[ -n $( command -v "${1}" ) ]]; then coder=$( command -v "${1}" ) && shift else echo "Please specify a coding agent" >&2 exit 1 fi if ! [[ " ${agents[@]} " =~ " ${coder} " ]]; then echo "No agent found" >&2 exit 1 fi declare -a ro_binds declare -a rw_binds declare -a bwrap_args declare -a bind_sources declare -a bind_destinations declare -a hidden_paths 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 ro_binds+=("${readonly_path}") fi done 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 done # Resolve mount sources, but keep the paths applications expect as destinations. for readonly_path in "${ro_binds[@]}"; do 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=$( resolve_path "${readwrite_path}" ) || exit 1 bwrap_args+=("--bind" "${bind_source}" "${readwrite_path}") bind_sources+=("${bind_source}") bind_destinations+=("${readwrite_path}") done git_config="${project_dir}/.git/config" git_worktree_config="${project_dir}/.git/config.worktree" if [[ -f "${project_dir}/.git" ]]; then # Linked worktrees keep their shared and per-worktree config elsewhere. git_config=$( git -C "${project_dir}" rev-parse --path-format=absolute --git-path config ) || exit 1 git_worktree_config=$( git -C "${project_dir}" rev-parse --path-format=absolute --git-path config.worktree ) || exit 1 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=$( 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 if [[ "${hide_path}" = "${bind_sources[${i}]}" || "${hide_path}" = "${bind_sources[${i}]}/"* ]]; then mask_path="${bind_destinations[${i}]}${hide_path#"${bind_sources[${i}]}"}" if [[ "${mask_path}" != "${hide_path}" ]]; then bwrap_args+=("--ro-bind" /dev/null "${mask_path}") fi fi done 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[@]}" \ --proc /proc \ --dev /dev \ --share-net \ --unshare-pid \ --die-with-parent \ --chdir "${project_dir}" \ "${coder}" "${@}"