|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Run a command with Codex's native sandbox and a generated permissions profile. |
| 3 | +set -euo pipefail |
| 4 | + |
| 5 | +usage() { |
| 6 | + cat <<'EOF' |
| 7 | +Usage: sbx [options] -- command [arguments...] |
| 8 | +
|
| 9 | +Run a command under `codex sandbox`. The command may read the filesystem, but |
| 10 | +may write only to the current workspace, /tmp, ~/.pi, ~/.config, XDG state and |
| 11 | +cache directories, pnpm data, Android, Java, Kotlin, and Gradle state, Codex's |
| 12 | +temporary directories, and any additional directories named with --write. |
| 13 | +
|
| 14 | +Options: |
| 15 | + -w, --write DIR Add a writable directory (repeatable) |
| 16 | + --print Print the generated Codex permissions profile and exit |
| 17 | + -h, --help Show this help |
| 18 | +
|
| 19 | +Examples: |
| 20 | + sbx -- pi |
| 21 | + sbx -w ../shared -- claude |
| 22 | + sbx -- npm test |
| 23 | +
|
| 24 | +Codex uses macOS sandbox-exec/Seatbelt behind this command on Apple platforms. |
| 25 | +EOF |
| 26 | +} |
| 27 | + |
| 28 | +die() { |
| 29 | + printf 'sbx: %s\n' "$*" >&2 |
| 30 | + exit 1 |
| 31 | +} |
| 32 | + |
| 33 | +resolve_dir() { |
| 34 | + local path=$1 resolved |
| 35 | + |
| 36 | + [[ -d $path ]] || die "directory does not exist: $path" |
| 37 | + resolved=$(cd -P -- "$path" && pwd) |
| 38 | + if [[ $resolved == *'"'* || $resolved =~ [[:cntrl:]] ]]; then |
| 39 | + die "Codex cannot use a writable path containing quotes or control characters: $resolved" |
| 40 | + fi |
| 41 | + printf '%s\n' "$resolved" |
| 42 | +} |
| 43 | + |
| 44 | +append_unique_root() { |
| 45 | + local root=$1 existing |
| 46 | + |
| 47 | + for existing in "${writable_roots[@]}"; do |
| 48 | + [[ $root != "$existing" ]] || return 0 |
| 49 | + done |
| 50 | + writable_roots+=("$root") |
| 51 | +} |
| 52 | + |
| 53 | +append_existing_root() { |
| 54 | + local root=$1 |
| 55 | + |
| 56 | + [[ ! -d $root ]] || append_unique_root "$(resolve_dir "$root")" |
| 57 | +} |
| 58 | + |
| 59 | +toml_quote() { |
| 60 | + local value=$1 |
| 61 | + |
| 62 | + value=${value//\\/\\\\} |
| 63 | + value=${value//\"/\\\"} |
| 64 | + value=${value//$'\b'/\\b} |
| 65 | + value=${value//$'\t'/\\t} |
| 66 | + value=${value//$'\n'/\\n} |
| 67 | + value=${value//$'\f'/\\f} |
| 68 | + value=${value//$'\r'/\\r} |
| 69 | + printf '"%s"' "$value" |
| 70 | +} |
| 71 | + |
| 72 | +print_profile=false |
| 73 | +writable_roots=() |
| 74 | +state=${XDG_STATE_HOME:-$HOME/.local/state} |
| 75 | +cache=${XDG_CACHE_HOME:-$HOME/.cache} |
| 76 | +pnpm_home=${PNPM_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/pnpm} |
| 77 | +append_unique_root "$(resolve_dir /tmp)" |
| 78 | +append_unique_root "$(resolve_dir "$HOME/.pi")" |
| 79 | +append_unique_root "$(resolve_dir "$HOME/.config")" |
| 80 | +append_existing_root "$PWD/.git" |
| 81 | +append_existing_root "$state" |
| 82 | +append_existing_root "$cache" |
| 83 | +append_existing_root "$pnpm_home" |
| 84 | +append_existing_root "$HOME/.cache" |
| 85 | +append_existing_root "$HOME/Library/Caches" |
| 86 | +append_existing_root "$HOME/.gradle" |
| 87 | +append_existing_root "${ANDROID_USER_HOME:-$HOME/.android}" |
| 88 | +append_existing_root "${ANDROID_AVD_HOME:-}" |
| 89 | +append_existing_root "$HOME/Library/Application Support/kotlin" |
| 90 | +append_existing_root "${JAVA_HOME:-}" |
| 91 | +if [[ -x /usr/libexec/java_home ]]; then |
| 92 | + append_existing_root "$(/usr/libexec/java_home 2>/dev/null || true)" |
| 93 | +fi |
| 94 | +for java_home in \ |
| 95 | + /opt/homebrew/opt/openjdk*/libexec/*.jdk/Contents/Home \ |
| 96 | + /usr/local/opt/openjdk*/libexec/*.jdk/Contents/Home; do |
| 97 | + append_existing_root "$java_home" |
| 98 | +done |
| 99 | + |
| 100 | +while (($#)); do |
| 101 | + case $1 in |
| 102 | + -w | --write) |
| 103 | + (($# >= 2)) || die "$1 requires a directory" |
| 104 | + resolved_root=$(resolve_dir "$2") || exit |
| 105 | + append_unique_root "$resolved_root" |
| 106 | + shift 2 |
| 107 | + ;; |
| 108 | + --print) |
| 109 | + print_profile=true |
| 110 | + shift |
| 111 | + ;; |
| 112 | + -h | --help) |
| 113 | + usage |
| 114 | + exit 0 |
| 115 | + ;; |
| 116 | + --) |
| 117 | + shift |
| 118 | + break |
| 119 | + ;; |
| 120 | + -*) |
| 121 | + die "unknown option: $1" |
| 122 | + ;; |
| 123 | + *) |
| 124 | + die "expected -- before the command" |
| 125 | + ;; |
| 126 | + esac |
| 127 | +done |
| 128 | + |
| 129 | +command -v codex >/dev/null || die "codex is required" |
| 130 | +if ! "$print_profile" && (($# == 0)); then |
| 131 | + die "no command specified" |
| 132 | +fi |
| 133 | + |
| 134 | +# Codex is the visible foreground process when it wraps an agent, so preserve |
| 135 | +# the actual agent identity for Herdr's process detection. |
| 136 | +if (($# > 0)) && [[ ${1##*/} == pi ]]; then |
| 137 | + export HERDR_AGENT=pi |
| 138 | +fi |
| 139 | + |
| 140 | +# Extend Codex's built-in workspace profile instead of maintaining a Seatbelt |
| 141 | +# policy here. Codex supplies the workspace and temporary write roots, protects |
| 142 | +# sensitive workspace metadata, and selects the platform sandbox implementation. |
| 143 | +profile='{ extends = ":workspace"' |
| 144 | +if ((${#writable_roots[@]})); then |
| 145 | + profile+=', workspace_roots = {' |
| 146 | + for path in "${writable_roots[@]}"; do |
| 147 | + profile+=" $(toml_quote "$path") = true," |
| 148 | + done |
| 149 | + profile+=' }' |
| 150 | +fi |
| 151 | +profile+=', network = { enabled = true } }' |
| 152 | + |
| 153 | +if "$print_profile"; then |
| 154 | + printf 'permissions.sbx = %s\n' "$profile" |
| 155 | + exit 0 |
| 156 | +fi |
| 157 | + |
| 158 | +XDG_STATE_HOME="$state" XDG_CACHE_HOME="$cache" exec codex sandbox \ |
| 159 | + -C "$PWD" \ |
| 160 | + -c "permissions.sbx=$profile" \ |
| 161 | + -P sbx \ |
| 162 | + -- "$@" |
0 commit comments