Split /codex mount per project

This commit is contained in:
Samuel Aubertin
2026-03-09 13:49:06 +01:00
parent 046b56e3f6
commit 47c3c979e5
4 changed files with 186 additions and 31 deletions

View File

@@ -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/<project-hash>`, 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 <session-id>` | Continues a Codex session by running `codex resume <session-id>` 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/<project-hash>` 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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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 >/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 >/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 >/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 >/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