From 47c3c979e55dc0a455642ed5910759d3361beabc Mon Sep 17 00:00:00 2001 From: Samuel Aubertin Date: Mon, 9 Mar 2026 13:49:06 +0100 Subject: [PATCH] Split /codex mount per project --- README.md | 18 +++---- sloptrap | 80 ++++++++++++++++++++++++------- tests/README.md | 3 ++ tests/run_tests.sh | 116 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 186 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 80fb71d..254efce 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,13 @@ brew install coreutils gnu-tar jq 3. Run `./sloptrap path/to/project`. On the first invocation sloptrap: - builds `path/to/project-sloptrap-image` if missing, - verifies the Codex binary hash, - - creates `${HOME}/.codex` and runs `login` if credentials are absent. + - creates `${HOME}/.codex`, prepares a per-project state directory, and runs `login` if `${HOME}/.codex/auth.json` is missing or empty. > Use `./sloptrap path/to/project shell` to enter a troubleshooting shell inside the container or `./sloptrap path/to/project clean` to remove cached images and state. ## How It Works -- The project directory mounts at `/workspace`, and `${HOME}/.codex` mounts at `/codex`. +- The project directory mounts at `/workspace`; project-scoped Codex state mounts at `/codex` from `${HOME}/.codex/sloptrap/state/`, and shared auth mounts from `${HOME}/.codex/auth.json` to `/codex/auth.json`. - `.sloptrapignore` entries (if present in your project) are overlaid by tmpfs (for directories) or empty bind mounts (for files) so Codex cannot read the masked content. - sloptrap launches containers on an isolated network (`bridge` on Docker, `slirp4netns` on Podman) with `--cap-drop=ALL`, `--security-opt no-new-privileges`, a read-only root filesystem, and tmpfs-backed `/tmp`, `/run`, and `/run/lock`. Projects that explicitly set `allow_host_network=true` in their manifest opt into `--network host`. - The helper Dockerfile is embedded inside `sloptrap`; set `SLOPTRAP_DOCKERFILE_PATH=/path/to/custom/Dockerfile` if you need to supply your own recipe. The default image installs `curl`, `bash`, `ca-certificates`, `libstdc++6`, `git`, `ripgrep`, `xxd`, and `file`, so most debugging helpers are already available without adding `packages_extra`. @@ -93,7 +93,7 @@ Behaviour: - Missing manifests are treated as default configuration; when a build is requested, sloptrap runs the interactive wizard if a TTY is available, otherwise it warns and continues with defaults. - `SLOPTRAP_CONTAINER_ENGINE` overrides engine auto-detection. -- If `${HOME}/.codex/auth.json` is absent, sloptrap prepends a login run before executing your targets. +- If `${HOME}/.codex/auth.json` is absent or empty, sloptrap prepends a login run before executing your targets. - Exit status mirrors the last target executed; errors in parsing or setup abort early with a message. `--print-config` fields include `manifest_present=true|false`, resolved paths, and the sanitised ignore mount roots so you can confirm what will be hidden inside the container. @@ -114,7 +114,7 @@ Targets are supplied after the code directory. When omitted, sloptrap defaults t | `rebuild` | Rebuild the image from scratch (`--no-cache`). | | `run` | Default goal. Runs the container with Codex as entrypoint and passes `codex_args`. | | `resume ` | Continues a Codex session by running `codex resume ` inside the container (builds if needed). | -| `login` | Starts Codex in login mode to bootstrap `${HOME}/.codex`. | +| `login` | Starts Codex in login mode to bootstrap shared `${HOME}/.codex/auth.json` credentials. | | `shell` | Launches `/bin/bash` inside the container for debugging. | | `wizzard` | Creates or updates `.sloptrap` interactively (no build); rerun `build` or `rebuild` afterward. | | `stop` | Best-effort stop of the running container (if any). | @@ -125,21 +125,21 @@ The launcher executes targets sequentially, so `./sloptrap repo build run` perfo ## Execution Environment - Container engine: Podman or podman with identical command lines. Podman uses `--userns=keep-id`; Docker receives the equivalent `--user UID:GID`. -- Filesystem view: the project directory mounts at `/workspace`; `${HOME}/.codex` mounts at `/codex`. +- Filesystem view: the project directory mounts at `/workspace`; `${HOME}/.codex/sloptrap/state/` mounts at `/codex`; `${HOME}/.codex/auth.json` mounts at `/codex/auth.json`. - Ignore filter: `.sloptrapignore` entries are overlaid with tmpfs directories or empty bind mounts so data remains unavailable to Codex. - Network: the container always runs with `--network host`. sloptrap does not filter or proxy outbound traffic. - Process context: capabilities are dropped, `no-new-privileges` is set, the root filesystem is read-only, and scratch paths (`/tmp`, `/run`, `/run/lock`) are tmpfs mounts. Resource limits follow the launcher defaults. -- Codex configuration: runtime flags come from `codex_args`. Persistent Codex state is stored under `${HOME}/.codex`. +- Codex configuration: runtime flags come from `codex_args`. Persistent Codex state is project-scoped under `${HOME}/.codex/sloptrap/state/`, while credentials are shared via `${HOME}/.codex/auth.json`. ## Threat Model and Limits - **Outbound disclosure**: prompts and referenced data travel from the container to the configured LLM endpoint. Any file content within `/workspace` or environment data exposed to the process can appear in that traffic. -- **Shared storage**: `/workspace` and `/codex` are the only host mounts. Files written to these locations become visible on the host and to the LLM provider through prompts. +- **Shared storage**: `/workspace`, project-scoped `/codex`, and `/codex/auth.json` are host mounts. Files written to these locations become visible on the host and to the LLM provider through prompts. - **Environment surface**: the container receives a minimal fixed environment (HOME/XDG paths, `CODEX_HOME`). The manifest no longer allows injecting additional environment variables. - **Process isolation**: the container runs without additional Linux capabilities and with a read-only root filesystem. The container and host still share the same kernel; a kernel-level escape would affect host confidentiality. - **Networking stance**: traffic is unrestricted once it leaves the container. sloptrap does not enforce an allowlist or DNS policy, and `--network host` is always used because the bundled Codex CLI must reach an upstream LLM provider. If you require an offline or firewalled workflow, sloptrap is not an appropriate launcher. -- **Persistence**: Codex history and logs accumulate under `${HOME}/.codex`. Sensitive prompts recorded on disk remain on the host after the session. Because `.git/` is ignored inside the container, any historical secrets in Git objects stay outside the LLM context unless explicitly surfaced in the working tree. -- **Codex cache hygiene**: the `${HOME}/.codex` mount remains writable by the container and will hold tokens, cached prompts, and other state. Rotate credentials regularly and avoid co-locating unrelated secrets inside that directory. +- **Persistence**: Codex history and logs accumulate per project under `${HOME}/.codex/sloptrap/state/`. Sensitive prompts recorded on disk remain on the host after the session. Because `.git/` is ignored inside the container, any historical secrets in Git objects stay outside the LLM context unless explicitly surfaced in the working tree. +- **Codex cache hygiene**: per-project state mounts remain writable by the container and hold prompts/history/state, while `${HOME}/.codex/auth.json` holds shared credentials. Rotate credentials regularly and protect both locations. - **Secret scanning**: sloptrap does not perform secret discovery or redaction; any credentials present in the project remain available to Codex and the upstream provider. - **Local model exception**: pointing Codex at a local or self-hosted model keeps data within the host network boundary, but the filesystem and environment exposure described above is unchanged. diff --git a/sloptrap b/sloptrap index 15817b0..f372378 100755 --- a/sloptrap +++ b/sloptrap @@ -225,7 +225,10 @@ MANIFEST_PRESENT=false CURRENT_IGNORE_FILE="" CONTAINER_ENGINE="" -CODEX_HOME_HOST="" +CODEX_ROOT_HOST="" +CODEX_STATE_HOME_HOST="" +CODEX_AUTH_FILE_HOST="" +CODEX_STATE_KEY="" CODEX_HOME_BOOTSTRAP=false NEED_LOGIN=false IGNORE_STUB_BASE="" @@ -372,13 +375,23 @@ select_codex_home() { error "expected Codex home '$preferred' to be a directory" fi - CODEX_HOME_HOST="$preferred" - if [[ -d $CODEX_HOME_HOST ]]; then - CODEX_HOME_HOST="$(cd "$CODEX_HOME_HOST" && pwd -P)" + CODEX_ROOT_HOST="$preferred" + if [[ -d $CODEX_ROOT_HOST ]]; then + CODEX_ROOT_HOST="$(cd "$CODEX_ROOT_HOST" && pwd -P)" CODEX_HOME_BOOTSTRAP=false else CODEX_HOME_BOOTSTRAP=true fi + CODEX_STATE_KEY=$(printf '%s' "$CODE_DIR" | sha256sum) + CODEX_STATE_KEY=${CODEX_STATE_KEY%% *} + CODEX_STATE_HOME_HOST="$CODEX_ROOT_HOST/sloptrap/state/$CODEX_STATE_KEY" + CODEX_AUTH_FILE_HOST="$CODEX_ROOT_HOST/auth.json" + if [[ -L $CODEX_AUTH_FILE_HOST ]]; then + error "Codex auth file '$CODEX_AUTH_FILE_HOST' must not be a symlink" + fi + if [[ -e $CODEX_AUTH_FILE_HOST && ! -f $CODEX_AUTH_FILE_HOST ]]; then + error "expected Codex auth file '$CODEX_AUTH_FILE_HOST' to be a regular file" + fi } assert_path_within_code_dir() { @@ -820,7 +833,11 @@ print_config() { info_line "container_engine=%s\n" "$CONTAINER_ENGINE" info_line "image_name=%s\n" "$SLOPTRAP_IMAGE_NAME" info_line "container_name=%s\n" "$SLOPTRAP_CONTAINER_NAME" - info_line "codex_home=%s\n" "$CODEX_HOME_HOST" + info_line "codex_home=%s\n" "$CODEX_STATE_HOME_HOST" + info_line "codex_root=%s\n" "$CODEX_ROOT_HOST" + info_line "codex_state_home=%s\n" "$CODEX_STATE_HOME_HOST" + info_line "codex_auth_file=%s\n" "$CODEX_AUTH_FILE_HOST" + info_line "codex_state_key=%s\n" "$CODEX_STATE_KEY" 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" @@ -942,15 +959,46 @@ run_or_print() { "$@" } -ensure_codex_home_dir() { - if [[ -d $CODEX_HOME_HOST ]]; then +ensure_codex_directory() { + local path=$1 + local label=$2 + if [[ -L $path ]]; then + error "$label '$path' must not be a symlink" + fi + if [[ -e $path && ! -d $path ]]; then + error "expected $label '$path' to be a directory" + fi + if [[ -d $path ]]; then return 0 fi if $DRY_RUN; then - print_command mkdir -p "$CODEX_HOME_HOST" + print_command mkdir -p "$path" return 0 fi - mkdir -p "$CODEX_HOME_HOST" + mkdir -p "$path" +} + +ensure_codex_storage_paths() { + local state_root="$CODEX_ROOT_HOST/sloptrap" + local state_bucket="$state_root/state" + ensure_codex_directory "$CODEX_ROOT_HOST" "Codex home" + ensure_codex_directory "$state_root" "sloptrap Codex namespace" + ensure_codex_directory "$state_bucket" "sloptrap Codex state root" + ensure_codex_directory "$CODEX_STATE_HOME_HOST" "project Codex state" + if [[ -L $CODEX_AUTH_FILE_HOST ]]; then + error "Codex auth file '$CODEX_AUTH_FILE_HOST' must not be a symlink" + fi + if [[ -e $CODEX_AUTH_FILE_HOST && ! -f $CODEX_AUTH_FILE_HOST ]]; then + error "expected Codex auth file '$CODEX_AUTH_FILE_HOST' to be a regular file" + fi + if [[ -f $CODEX_AUTH_FILE_HOST ]]; then + return 0 + fi + if $DRY_RUN; then + print_command touch "$CODEX_AUTH_FILE_HOST" + return 0 + fi + : > "$CODEX_AUTH_FILE_HOST" } fetch_latest_codex_digest() { @@ -1175,7 +1223,8 @@ prepare_container_runtime() { local -a volume_opts=( -v "$SLOPTRAP_SHARED_DIR_ABS:$SLOPTRAP_WORKDIR$SLOPTRAP_VOLUME_LABEL" - -v "$CODEX_HOME_HOST:$SLOPTRAP_CODEX_HOME_CONT$SLOPTRAP_VOLUME_LABEL" + -v "$CODEX_STATE_HOME_HOST:$SLOPTRAP_CODEX_HOME_CONT$SLOPTRAP_VOLUME_LABEL" + -v "$CODEX_AUTH_FILE_HOST:$SLOPTRAP_CODEX_HOME_CONT/auth.json$SLOPTRAP_VOLUME_LABEL" ) local -a env_args=( @@ -1361,6 +1410,7 @@ prune_sloptrap_images() { run_codex_command() { local -a extra_args=("$@") + ensure_codex_storage_paths local -a cmd=("${BASE_CONTAINER_CMD[@]}" "$SLOPTRAP_IMAGE_NAME") if [[ ${#CODEX_ARGS_ARRAY[@]} -gt 0 ]]; then cmd+=("${CODEX_ARGS_ARRAY[@]}") @@ -1379,6 +1429,7 @@ run_codex() { } run_login_target() { + ensure_codex_storage_paths if ! $DRY_RUN; then status_line "Login %s\n" "$SLOPTRAP_IMAGE_NAME" fi @@ -1387,6 +1438,7 @@ run_login_target() { } run_shell_target() { + ensure_codex_storage_paths if ! $DRY_RUN; then status_line "Shell %s\n" "$SLOPTRAP_IMAGE_NAME" fi @@ -1429,7 +1481,6 @@ dispatch_target() { ;; login) build_if_missing - ensure_codex_home_dir run_login_target ;; shell) @@ -1553,9 +1604,7 @@ IGNORE_STUB_BASE="$IGNORE_HELPER_ROOT/session-${BASHPID:-$$}" resolve_sloptrap_ignore "$CODE_DIR" resolve_container_workdir NEED_LOGIN=false -if [[ $CODEX_HOME_BOOTSTRAP == true ]]; then - NEED_LOGIN=true -elif [[ ! -f "$CODEX_HOME_HOST/auth.json" ]]; then +if [[ ! -s "$CODEX_AUTH_FILE_HOST" ]]; then NEED_LOGIN=true fi @@ -1653,9 +1702,8 @@ fi if $AUTO_LOGIN; then if ! $DRY_RUN; then - status_line "Codex login required (%s)\n" "$CODEX_HOME_HOST" + status_line "Codex login required (%s)\n" "$CODEX_AUTH_FILE_HOST" fi - ensure_codex_home_dir dispatch_target login fi diff --git a/tests/README.md b/tests/README.md index b45adda..82ac6c3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,3 +11,6 @@ Current scenarios: - `helper_symlink/` — ensures `.sloptrap-ignores` cannot be a symlink to directories outside the project. - `secret_mask/` — verifies masked files remain hidden even when sloptrap remaps the workspace mount. - `resume_target/` — verifies the resume target passes the requested session identifier to Codex. +- `auth_file_mount` — verifies `~/.codex/auth.json` is mounted directly into `/codex/auth.json`. +- `project_state_isolation` — verifies different projects map `/codex` to different host state directories. +- `auto_login_empty_auth` — verifies an empty `auth.json` still triggers automatic login before the main target. diff --git a/tests/run_tests.sh b/tests/run_tests.sh index c5997b7..83ccc16 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -15,6 +15,16 @@ fi failures=() +can_run_script_pty() { + if ! command -v script >/dev/null 2>&1; then + return 1 + fi + if ! script -q -c "true" /dev/null >/dev/null 2>&1; then + return 1 + fi + return 0 +} + run_shellcheck() { printf '==> shellcheck\n' if ! command -v shellcheck >/dev/null 2>&1; then @@ -154,6 +164,9 @@ if [[ ${1-} == "-c" ]]; then exit 0 fi fi +if [[ -x /usr/bin/sha256sum ]]; then + exec /usr/bin/sha256sum "$@" +fi printf 'sha256sum stub encountered unsupported args: %s\n' "$*" >&2 exit 1 EOF @@ -306,6 +319,94 @@ run_resume_target() { teardown_stub_env } +run_auth_file_mount() { + local scenario_dir + scenario_dir=$(cd "$TEST_ROOT/resume_target" && pwd -P) + printf '==> auth_file_mount\n' + setup_stub_env + mkdir -p "$STUB_HOME/.codex" + printf '{"access_token":"test"}\n' >"$STUB_HOME/.codex/auth.json" + if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" FAKE_PODMAN_INSPECT_FAIL=1 \ + "$SLOPTRAP_BIN" "$scenario_dir" /dev/null 2>&1; then + record_failure "auth_file_mount: sloptrap exited non-zero" + teardown_stub_env + return + fi + if ! grep -q -- "-v ${STUB_HOME}/.codex/auth.json:/codex/auth.json:Z" "$STUB_LOG"; then + record_failure "auth_file_mount: missing auth file bind mount" + fi + if ! grep -q -- "-v ${STUB_HOME}/.codex/sloptrap/state/" "$STUB_LOG"; then + record_failure "auth_file_mount: missing project state bind mount" + fi + teardown_stub_env +} + +run_project_state_isolation() { + local scenario_a scenario_b + scenario_a=$(cd "$TEST_ROOT/resume_target" && pwd -P) + scenario_b=$(cd "$TEST_ROOT/secret_mask" && pwd -P) + printf '==> project_state_isolation\n' + setup_stub_env + mkdir -p "$STUB_HOME/.codex" + printf '{"access_token":"test"}\n' >"$STUB_HOME/.codex/auth.json" + if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" FAKE_PODMAN_INSPECT_FAIL=1 \ + "$SLOPTRAP_BIN" "$scenario_a" /dev/null 2>&1; then + record_failure "project_state_isolation: first project run failed" + teardown_stub_env + return + fi + if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" \ + "$SLOPTRAP_BIN" "$scenario_b" /dev/null 2>&1; then + record_failure "project_state_isolation: second project run failed" + teardown_stub_env + return + fi + local -a codex_mounts=() + mapfile -t codex_mounts < <( + { + grep "FAKE PODMAN: run " "$STUB_LOG" \ + | grep -oE -- "-v [^ ]+:/codex:Z" \ + | sed -e 's/^-v //' -e 's/:\/codex:Z$//' + } || true + ) + if [[ ${#codex_mounts[@]} -lt 2 ]]; then + record_failure "project_state_isolation: missing /codex state mounts" + teardown_stub_env + return + fi + if [[ ${codex_mounts[0]} == "${codex_mounts[1]}" ]]; then + record_failure "project_state_isolation: projects reused same Codex state mount" + fi + if [[ ${codex_mounts[0]} != */.codex/sloptrap/state/* || ${codex_mounts[1]} != */.codex/sloptrap/state/* ]]; then + record_failure "project_state_isolation: state mounts did not use sloptrap namespace" + fi + teardown_stub_env +} + +run_auto_login_empty_auth() { + local scenario_dir + scenario_dir=$(cd "$TEST_ROOT/resume_target" && pwd -P) + printf '==> auto_login_empty_auth\n' + setup_stub_env + mkdir -p "$STUB_HOME/.codex" + : >"$STUB_HOME/.codex/auth.json" + if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" FAKE_PODMAN_INSPECT_FAIL=1 \ + "$SLOPTRAP_BIN" "$scenario_dir" /dev/null 2>&1; then + record_failure "auto_login_empty_auth: sloptrap exited non-zero" + teardown_stub_env + return + fi + local first_run + first_run=$(grep "FAKE PODMAN: run " "$STUB_LOG" | head -n 1 || true) + if [[ -z $first_run || $first_run != *" login" ]]; then + record_failure "auto_login_empty_auth: expected login before primary run" + fi + if ! grep -q -- "-v ${STUB_HOME}/.codex/auth.json:/codex/auth.json:Z" "$STUB_LOG"; then + record_failure "auto_login_empty_auth: missing auth file bind mount" + fi + teardown_stub_env +} + run_codex_symlink_home() { local scenario_dir scenario_dir=$(cd "$TEST_ROOT/resume_target" && pwd -P) @@ -418,8 +519,8 @@ run_invalid_allow_host_network() { run_wizzard_create_manifest() { local scenario_dir="$TEST_ROOT/wizzard_empty" printf '==> wizzard_create_manifest\n' - if ! command -v script >/dev/null 2>&1; then - printf 'skipping wizzard_create_manifest: script binary not found in PATH\n' + if ! can_run_script_pty; then + printf 'skipping wizzard_create_manifest: script PTY support not available\n' return fi rm -f "$scenario_dir/.sloptrap" @@ -449,8 +550,8 @@ run_wizzard_create_manifest() { run_wizzard_existing_defaults() { local scenario_dir="$TEST_ROOT/wizzard_existing" printf '==> wizzard_existing_defaults\n' - if ! command -v script >/dev/null 2>&1; then - printf 'skipping wizzard_existing_defaults: script binary not found in PATH\n' + if ! can_run_script_pty; then + printf 'skipping wizzard_existing_defaults: script PTY support not available\n' return fi local input=$'\n\n\n\n\n' @@ -475,8 +576,8 @@ run_wizzard_existing_defaults() { run_wizzard_build_trigger() { local scenario_dir="$TEST_ROOT/wizzard_build" printf '==> wizzard_build_trigger\n' - if ! command -v script >/dev/null 2>&1; then - printf 'skipping wizzard_build_trigger: script binary not found in PATH\n' + if ! can_run_script_pty; then + printf 'skipping wizzard_build_trigger: script PTY support not available\n' return fi setup_stub_env @@ -504,6 +605,9 @@ run_manifest_injection run_helper_symlink run_secret_mask run_resume_target +run_auth_file_mount +run_project_state_isolation +run_auto_login_empty_auth run_codex_symlink_home run_root_directory_project run_shared_dir_override