Compare commits

...

2 Commits

Author SHA1 Message Date
Samuel Aubertin
7630e7edba MacOS + Docker shenanigans 2025-11-27 16:12:46 +01:00
Samuel Aubertin
c1e64bb4ef More tests 2025-11-27 16:12:22 +01:00
10 changed files with 296 additions and 14 deletions

View File

@@ -9,6 +9,14 @@ sloptrap runs the OpenAI Codex CLI inside a container with a predictable and loc
> Tip: set `SLOPTRAP_CONTAINER_ENGINE=<engine>` if you need to override the default Podman requirement.
### macOS setup
sloptrap targets GNU userland. On macOS, install the GNU tools via Homebrew and the launcher will prepend their `gnubin` paths automatically:
```
brew install coreutils gnu-tar jq
```
## Quick Start
1. Place `sloptrap` somewhere on your PATH/shared drive (the helper Dockerfile and Codex binary are bundled and downloaded automatically).

173
sloptrap
View File

@@ -2,7 +2,57 @@
# sloptrap
set -euo pipefail
require_cmd() { command -v "$1" >/dev/null 2>&1 || error "'$1' is required"; }
IS_MAC=false
if [[ $(uname -s 2>/dev/null) == "Darwin" ]]; then
IS_MAC=true
mac_gnu_bins=(
/opt/homebrew/opt/coreutils/libexec/gnubin
/usr/local/opt/coreutils/libexec/gnubin
/opt/homebrew/opt/gnu-tar/libexec/gnubin
/usr/local/opt/gnu-tar/libexec/gnubin
)
for bin_dir in "${mac_gnu_bins[@]}"; do
[[ -d $bin_dir ]] || continue
case ":$PATH:" in
*":$bin_dir:"*)
;;
*)
path_head=${PATH%%:*}
path_rest=""
if [[ $PATH == *:* ]]; then
path_rest=${PATH#*:}
fi
case "$path_head" in
/tmp/*|/var/folders/*|"$HOME"/*)
if [[ -n $path_rest ]]; then
PATH="$path_head:$bin_dir:$path_rest"
else
PATH="$path_head:$bin_dir"
fi
;;
*)
PATH="$bin_dir:$PATH"
;;
esac
unset -v path_head path_rest
;;
esac
done
export PATH
fi
REQUIRED_BREW_PKGS="coreutils gnu-tar jq"
require_cmd() {
if command -v "$1" >/dev/null 2>&1; then
return 0
fi
if $IS_MAC; then
error "'$1' is required; install with: brew install $REQUIRED_BREW_PKGS"
else
error "'$1' is required"
fi
}
for c in curl tar sha256sum realpath jq; do require_cmd "$c"; done
COLOR_TEXT=$'\033[38;5;247m'
@@ -147,6 +197,26 @@ trim() {
printf '%s' "$value"
}
resolve_path_relaxed() {
local candidate=$1
local resolved
if resolved=$(realpath -m "$candidate" 2>/dev/null); then
printf '%s' "$resolved"
return 0
fi
return 1
}
resolve_path_strict() {
local candidate=$1
local resolved
if resolved=$(resolve_path_relaxed "$candidate"); then
printf '%s' "$resolved"
return 0
fi
error "failed to resolve path '$candidate'; install GNU coreutils for realpath support"
}
declare -A MANIFEST=()
declare -a SLOPTRAP_IGNORE_ENTRIES=()
declare -a IGNORE_MOUNT_ARGS=()
@@ -181,11 +251,11 @@ cleanup_ignore_stub_dir() {
[[ -n $helper_root && -n $stub_base ]] || return 0
[[ -d $stub_base ]] || return 0
local resolved_helper resolved_stub
if ! resolved_helper=$(realpath -m "$helper_root") 2>/dev/null; then
if ! resolved_helper=$(resolve_path_relaxed "$helper_root"); then
warn "failed to resolve helper root '$helper_root' during cleanup"
return 0
fi
if ! resolved_stub=$(realpath -m "$stub_base") 2>/dev/null; then
if ! resolved_stub=$(resolve_path_relaxed "$stub_base"); then
warn "failed to resolve helper stub '$stub_base' during cleanup"
return 0
fi
@@ -269,6 +339,18 @@ validate_basename() {
[[ $name =~ ^[A-Za-z0-9._+-]+$ ]] || error "invalid basename '$name'"
}
sanitize_engine_name() {
local name=$1
local lowered=${name,,}
if [[ $lowered != "$name" ]]; then
warn "normalizing name '$name' to '$lowered' for container engine compatibility"
fi
if [[ ! $lowered =~ ^[a-z0-9_.-]+$ ]]; then
error "engine name '$name' is invalid after normalization"
fi
printf '%s' "$lowered"
}
prepare_build_context() {
if [[ -n $SLOPTRAP_BUILD_CONTEXT && -d $SLOPTRAP_BUILD_CONTEXT ]]; then
return 0
@@ -282,6 +364,9 @@ prepare_build_context() {
select_codex_home() {
local preferred="$HOME/.codex"
if [[ -L $preferred ]]; then
error "Codex home '$preferred' must not be a symlink"
fi
if [[ -e $preferred && ! -d $preferred ]]; then
error "expected Codex home '$preferred' to be a directory"
fi
@@ -298,7 +383,7 @@ select_codex_home() {
assert_path_within_code_dir() {
local candidate=$1
local resolved
resolved=$(realpath -m "$candidate")
resolved=$(resolve_path_strict "$candidate")
if [[ $resolved != "$CODE_DIR" && $resolved != "$CODE_DIR/"* ]]; then
error "path '$candidate' escapes project root '$CODE_DIR'"
fi
@@ -345,7 +430,7 @@ sanitize_ignore_rel() {
error "$source: .sloptrapignore entry '$original' contains control characters"
fi
local resolved
resolved=$(realpath -m "$CODE_DIR/$rel")
resolved=$(resolve_path_strict "$CODE_DIR/$rel")
if [[ $resolved != "$CODE_DIR" && $resolved != "$CODE_DIR/"* ]]; then
error "$source: .sloptrapignore entry '$original' resolves outside the project root"
fi
@@ -529,11 +614,12 @@ ensure_safe_for_make() {
validate_package_list() {
local key=$1
local raw=$2
local source=${3:-$MANIFEST_PATH}
[[ -z $raw ]] && return 0
local token
for token in $raw; do
if [[ ! $token =~ ^[A-Za-z0-9+.-]+$ ]]; then
error "$MANIFEST_PATH: invalid package name '$token' in '$key'"
error "$source: invalid package name '$token' in '$key'"
fi
done
}
@@ -552,7 +638,11 @@ detect_container_engine() {
printf 'podman'
return 0
fi
error "podman is required but was not found in PATH; set SLOPTRAP_CONTAINER_ENGINE to override explicitly"
if command -v docker >/dev/null 2>&1; then
printf 'docker'
return 0
fi
error "container engine not found in PATH; install podman (preferred) or docker, or set SLOPTRAP_CONTAINER_ENGINE explicitly"
}
parse_manifest() {
@@ -592,6 +682,8 @@ print_config() {
info_line "container_name=%s\n" "$SLOPTRAP_CONTAINER_NAME"
info_line "codex_home=%s\n" "$CODEX_HOME_HOST"
info_line "codex_home_bootstrap=%s\n" "$CODEX_HOME_BOOTSTRAP"
info_line "codex_archive=%s\n" "$SLOPTRAP_CODEX_ARCHIVE"
info_line "codex_url=%s\n" "$SLOPTRAP_CODEX_URL"
info_line "needs_login=%s\n" "$NEED_LOGIN"
info_line "codex_args=%s\n" "$CODEX_ARGS_DISPLAY"
info_line "ignore_stub_base=%s\n" "$IGNORE_STUB_BASE"
@@ -626,6 +718,7 @@ SLOPTRAP_PACKAGES_BASE=""
SLOPTRAP_PACKAGES_EXTRA_RESOLVED=""
SLOPTRAP_CODEX_BIN_NAME=""
SLOPTRAP_CODEX_URL=""
SLOPTRAP_CODEX_ARCHIVE=""
SLOPTRAP_CODEX_HOME_CONT=""
SLOPTRAP_SECURITY_OPTS_EXTRA=""
SLOPTRAP_VOLUME_LABEL=""
@@ -650,6 +743,29 @@ get_env_default() {
fi
}
validate_codex_archive_name() {
local name=$1
[[ $name =~ ^codex-[A-Za-z0-9_.-]+$ ]] || error "invalid Codex archive name '$name'"
}
detect_codex_archive_name() {
local os arch codex_os codex_arch
os=$(uname -s 2>/dev/null || true)
arch=$(uname -m 2>/dev/null || true)
[[ -n $os ]] || error "failed to detect host OS for Codex download"
[[ -n $arch ]] || error "failed to detect host architecture for Codex download"
case "$os" in
Linux|Darwin) codex_os="unknown-linux-gnu" ;; # Codex runs inside a Debian-based image
*) error "unsupported host OS '$os' for Codex download" ;;
esac
case "$arch" in
x86_64|amd64) codex_arch="x86_64" ;;
arm64|aarch64) codex_arch="aarch64" ;;
*) error "unsupported host architecture '$arch' for Codex download" ;;
esac
printf 'codex-%s-%s' "$codex_arch" "$codex_os"
}
resolve_container_workdir() {
if [[ -z ${SLOPTRAP_WORKDIR:-} ]]; then
SLOPTRAP_WORKDIR=$(get_env_default "SLOPTRAP_WORKDIR" "/workspace")
@@ -690,6 +806,8 @@ ensure_codex_home_dir() {
fetch_latest_codex_digest() {
local api_url="https://api.github.com/repos/openai/codex/releases/latest"
local target_asset="${SLOPTRAP_CODEX_ARCHIVE}.tar.gz"
[[ -n $SLOPTRAP_CODEX_ARCHIVE ]] || error "Codex archive name is not set"
if ! command -v jq >/dev/null 2>&1; then
error "jq is required to verify the Codex binary digest"
fi
@@ -698,7 +816,7 @@ fetch_latest_codex_digest() {
error "failed to download Codex release metadata from GitHub"
fi
local digest_line
digest_line=$(jq -r '.assets[] | select(.name == "codex-x86_64-unknown-linux-gnu.tar.gz") | .digest' <<<"$response" | head -n 1)
digest_line=$(jq -r --arg name "$target_asset" '.assets[] | select(.name == $name) | .digest' <<<"$response" | head -n 1)
if [[ -z $digest_line || $digest_line == "null" ]]; then
error "failed to resolve Codex digest from GitHub response"
fi
@@ -711,14 +829,15 @@ ensure_codex_binary() {
if [[ -x $CODEX_BIN_PATH ]]; then
return 0
fi
local tar_transform="s/${SLOPTRAP_CODEX_ARCHIVE}/${SLOPTRAP_CODEX_BIN_NAME}/"
local download_dir
download_dir=$(create_temp_dir "codex")
local tmp_archive="$download_dir/codex.tar.gz"
if $DRY_RUN; then
print_command curl -Lso "$tmp_archive" "$SLOPTRAP_CODEX_URL"
print_command sha256sum -c -
print_command tar -xzf "$tmp_archive" --transform="s/codex-x86_64-unknown-linux-gnu/$SLOPTRAP_CODEX_BIN_NAME/" -C "$SLOPTRAP_BUILD_CONTEXT"
print_command chmod +x "$CODEX_BIN_PATH"
print_command tar -xzf "$tmp_archive" --transform="$tar_transform" -C "$SLOPTRAP_BUILD_CONTEXT"
print_command chmod 0755 "$CODEX_BIN_PATH"
return 0
fi
if ! curl -Lso "$tmp_archive" "$SLOPTRAP_CODEX_URL"; then
@@ -731,12 +850,12 @@ ensure_codex_binary() {
rm -rf "$download_dir" "$CODEX_BIN_PATH"
error "checksum verification failed for $SLOPTRAP_CODEX_BIN_NAME"
fi
if ! tar -xzf "$tmp_archive" --transform="s/codex-x86_64-unknown-linux-gnu/$SLOPTRAP_CODEX_BIN_NAME/" -C "$SLOPTRAP_BUILD_CONTEXT"; then
if ! tar -xzf "$tmp_archive" --transform="$tar_transform" -C "$SLOPTRAP_BUILD_CONTEXT"; then
rm -rf "$download_dir"
error "failed to extract Codex binary"
fi
rm -rf "$download_dir"
chmod +x "$CODEX_BIN_PATH"
chmod 0755 "$CODEX_BIN_PATH"
}
ensure_safe_sandbox() {
@@ -776,7 +895,7 @@ normalize_package_list() {
prepare_container_runtime() {
resolve_container_workdir
SLOPTRAP_PACKAGES_EXTRA_RESOLVED=$PACKAGES_EXTRA
SLOPTRAP_SHARED_DIR_ABS=$(get_env_default "SLOPTRAP_SHARED_DIR" "$CODE_DIR")
SLOPTRAP_SHARED_DIR_ABS="$CODE_DIR"
if [[ ! -d $SLOPTRAP_SHARED_DIR_ABS ]]; then
error "shared directory '$SLOPTRAP_SHARED_DIR_ABS' does not exist"
fi
@@ -799,7 +918,28 @@ prepare_container_runtime() {
fi
SLOPTRAP_PACKAGES_BASE=$(get_env_default "SLOPTRAP_PACKAGES" "curl bash ca-certificates libstdc++6 git ripgrep xxd file procps")
SLOPTRAP_CODEX_URL=$(get_env_default "SLOPTRAP_CODEX_URL" "https://github.com/openai/codex/releases/latest/download/codex-x86_64-unknown-linux-gnu.tar.gz")
validate_package_list "SLOPTRAP_PACKAGES" "$SLOPTRAP_PACKAGES_BASE" "SLOPTRAP_PACKAGES"
local default_codex_archive
default_codex_archive=$(detect_codex_archive_name)
local env_codex_archive
env_codex_archive=$(printenv "SLOPTRAP_CODEX_ARCHIVE" 2>/dev/null || true)
if [[ -n $env_codex_archive ]]; then
SLOPTRAP_CODEX_ARCHIVE=$env_codex_archive
else
SLOPTRAP_CODEX_ARCHIVE=$default_codex_archive
fi
validate_codex_archive_name "$SLOPTRAP_CODEX_ARCHIVE"
local default_codex_url="https://github.com/openai/codex/releases/latest/download/${SLOPTRAP_CODEX_ARCHIVE}.tar.gz"
SLOPTRAP_CODEX_URL=$(get_env_default "SLOPTRAP_CODEX_URL" "$default_codex_url")
if [[ -z $env_codex_archive ]]; then
local inferred_archive
inferred_archive=$(basename "${SLOPTRAP_CODEX_URL%%\?*}")
inferred_archive=${inferred_archive%.tar.gz}
if [[ $inferred_archive == codex-* ]]; then
validate_codex_archive_name "$inferred_archive"
SLOPTRAP_CODEX_ARCHIVE=$inferred_archive
fi
fi
SLOPTRAP_CODEX_BIN_NAME=$(get_env_default "SLOPTRAP_CODEX_BIN" "codex")
SLOPTRAP_CODEX_HOME_CONT=$(get_env_default "SLOPTRAP_CODEX_HOME_CONT" "/codex")
SLOPTRAP_CODEX_UID=$(get_env_default "SLOPTRAP_CODEX_UID" "1337")
@@ -828,6 +968,8 @@ prepare_container_runtime() {
SLOPTRAP_ROOTFS_READONLY=$(get_env_default "SLOPTRAP_ROOTFS_READONLY" "1")
SLOPTRAP_IMAGE_NAME=$(get_env_default "SLOPTRAP_IMAGE_NAME" "${PROJECT_NAME}-sloptrap-image")
SLOPTRAP_CONTAINER_NAME=$(get_env_default "SLOPTRAP_CONTAINER_NAME" "${PROJECT_NAME}-sloptrap-container")
SLOPTRAP_IMAGE_NAME=$(sanitize_engine_name "$SLOPTRAP_IMAGE_NAME")
SLOPTRAP_CONTAINER_NAME=$(sanitize_engine_name "$SLOPTRAP_CONTAINER_NAME")
local -a network_opts=(--network "$SLOPTRAP_NETWORK_NAME" --init)
local -a security_opts=(--cap-drop=ALL --security-opt no-new-privileges)
@@ -1167,6 +1309,9 @@ if [[ ! -d $CODE_DIR_INPUT ]]; then
fi
CODE_DIR="$(cd "$CODE_DIR_INPUT" && pwd -P)"
if [[ $CODE_DIR == "/" ]]; then
error "project root may not be '/'"
fi
MANIFEST_PATH="$CODE_DIR/$MANIFEST_BASENAME"
if [[ -f $MANIFEST_PATH ]]; then

View File

@@ -0,0 +1 @@
/../outside.txt

View File

@@ -0,0 +1 @@
../outside.txt

View File

@@ -0,0 +1,2 @@
name=invalid-allow-host
allow_host_network=maybe

View File

@@ -0,0 +1 @@
name=bad/name

View File

@@ -0,0 +1,2 @@
name=invalid-packages
packages_extra=curl$bad

View File

@@ -0,0 +1,2 @@
name=invalid-sandbox
codex_args=--sandbox host

1
tests/outside.txt Normal file
View File

@@ -0,0 +1 @@
outside

View File

@@ -306,6 +306,115 @@ run_resume_target() {
teardown_stub_env
}
run_codex_symlink_home() {
local scenario_dir
scenario_dir=$(cd "$TEST_ROOT/resume_target" && pwd -P)
printf '==> codex_symlink_home\n'
local tmp_home
tmp_home=$(mktemp -d)
ln -s /etc "$tmp_home/.codex"
if HOME="$tmp_home" "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then
record_failure "codex_symlink_home: expected rejection when ~/.codex is a symlink"
fi
rm -rf "$tmp_home"
}
run_root_directory_project() {
printf '==> root_directory_project\n'
local tmp_home
tmp_home=$(mktemp -d)
if HOME="$tmp_home" "$SLOPTRAP_BIN" --dry-run / >/dev/null 2>&1; then
record_failure "root_directory_project: expected rejection for '/' project root"
fi
rm -rf "$tmp_home"
}
run_shared_dir_override() {
local scenario_dir
scenario_dir=$(cd "$TEST_ROOT/resume_target" && pwd -P)
printf '==> shared_dir_override\n'
setup_stub_env
local bogus_shared
bogus_shared=$(mktemp -d)
if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" \
SLOPTRAP_SHARED_DIR="$bogus_shared" FAKE_PODMAN_INSPECT_FAIL=1 \
"$SLOPTRAP_BIN" "$scenario_dir" >/dev/null 2>&1; then
record_failure "shared_dir_override: sloptrap exited non-zero"
teardown_stub_env
rm -rf "$bogus_shared"
return
fi
if grep -q "$bogus_shared" "$STUB_LOG"; then
record_failure "shared_dir_override: respected SLOPTRAP_SHARED_DIR override"
fi
if ! grep -q -- "-v ${scenario_dir}:/workspace" "$STUB_LOG"; then
record_failure "shared_dir_override: missing expected project bind mount"
fi
teardown_stub_env
rm -rf "$bogus_shared"
}
run_packages_env_validation() {
local scenario_dir
scenario_dir=$(cd "$TEST_ROOT/resume_target" && pwd -P)
printf '==> packages_env_validation\n'
local tmp_home
tmp_home=$(mktemp -d)
if HOME="$tmp_home" SLOPTRAP_PACKAGES='curl";touch /tmp/pwn #' \
"$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then
record_failure "packages_env_validation: expected rejection of invalid SLOPTRAP_PACKAGES"
fi
rm -rf "$tmp_home"
}
run_abs_path_ignore() {
local scenario_dir="$TEST_ROOT/abs_path_ignore"
printf '==> abs_path_ignore\n'
if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then
record_failure "abs_path_ignore: expected rejection for anchored parent traversal entry"
fi
}
run_dotdot_ignore() {
local scenario_dir="$TEST_ROOT/dotdot_ignore"
printf '==> dotdot_ignore\n'
if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then
record_failure "dotdot_ignore: expected rejection for parent traversal entry"
fi
}
run_invalid_manifest_name() {
local scenario_dir="$TEST_ROOT/invalid_manifest_name"
printf '==> invalid_manifest_name\n'
if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then
record_failure "invalid_manifest_name: expected rejection for illegal name"
fi
}
run_invalid_manifest_sandbox() {
local scenario_dir="$TEST_ROOT/invalid_manifest_sandbox"
printf '==> invalid_manifest_sandbox\n'
if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then
record_failure "invalid_manifest_sandbox: expected rejection for sandbox mode"
fi
}
run_invalid_manifest_packages() {
local scenario_dir="$TEST_ROOT/invalid_manifest_packages"
printf '==> invalid_manifest_packages\n'
if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then
record_failure "invalid_manifest_packages: expected rejection for bad packages"
fi
}
run_invalid_allow_host_network() {
local scenario_dir="$TEST_ROOT/invalid_allow_host_network"
printf '==> invalid_allow_host_network\n'
if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then
record_failure "invalid_allow_host_network: expected rejection for invalid value"
fi
}
run_shellcheck
run_mount_injection
run_root_target
@@ -314,6 +423,16 @@ run_manifest_injection
run_helper_symlink
run_secret_mask
run_resume_target
run_codex_symlink_home
run_root_directory_project
run_shared_dir_override
run_packages_env_validation
run_abs_path_ignore
run_dotdot_ignore
run_invalid_manifest_name
run_invalid_manifest_sandbox
run_invalid_manifest_packages
run_invalid_allow_host_network
if [[ ${#failures[@]} -gt 0 ]]; then
printf '\nTest failures:\n'