#!/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}" "${@}"
