From 636098a15bdc9e3a1a4cdc2012fe3a66b337b107 Mon Sep 17 00:00:00 2001 From: Samuel Aubertin Date: Sat, 8 Nov 2025 03:59:16 +0100 Subject: [PATCH] alpha --- .gitignore | 1 + .sloptrap | 15 + .sloptrapignore | 7 + LICENSE | 13 + Makefile | 50 + README.md | 142 ++ sloptrap | 1248 +++++++++++++++++ tests/README.md | 12 + tests/helper_symlink/.sloptrap-ignores | 1 + tests/helper_symlink/.sloptrapignore | 1 + tests/manifest_injection/.sloptrap | 1 + tests/mount_injection/.gitignore | 1 + tests/mount_injection/.sloptrapignore | 1 + .../mount_injection/attack,source=/etc/passwd | 1 + tests/root_target/.sloptrapignore | 1 + tests/run_tests.sh | 308 ++++ tests/secret_mask/.sloptrapignore | 1 + tests/secret_mask/secret.txt | 1 + tests/symlink_escape/.sloptrapignore | 1 + tests/symlink_escape/cheat | 1 + 20 files changed, 1807 insertions(+) create mode 100644 .gitignore create mode 100644 .sloptrap create mode 100644 .sloptrapignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100755 sloptrap create mode 100644 tests/README.md create mode 120000 tests/helper_symlink/.sloptrap-ignores create mode 100644 tests/helper_symlink/.sloptrapignore create mode 100644 tests/manifest_injection/.sloptrap create mode 100644 tests/mount_injection/.gitignore create mode 100644 tests/mount_injection/.sloptrapignore create mode 100644 tests/mount_injection/attack,source=/etc/passwd create mode 100644 tests/root_target/.sloptrapignore create mode 100755 tests/run_tests.sh create mode 100644 tests/secret_mask/.sloptrapignore create mode 100644 tests/secret_mask/secret.txt create mode 100644 tests/symlink_escape/.sloptrapignore create mode 120000 tests/symlink_escape/cheat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2695308 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +codex diff --git a/.sloptrap b/.sloptrap new file mode 100644 index 0000000..e6b1ced --- /dev/null +++ b/.sloptrap @@ -0,0 +1,15 @@ +# Example Sloptrap manifest. +# Project identifier used for container/image naming. +name=sloptrap + +# Default targets invoked when ./sloptrap is run without explicit args. +# default_targets=run + +# Extra Debian packages installed into the helper image (space-delimited list). +packages_extra=make shellcheck jq + +# Additional Codex CLI switches appended when launching Codex. +codex_args=--sandbox workspace-write + +# Allow the container host to be reachable from within +# allow_host_network=false diff --git a/.sloptrapignore b/.sloptrapignore new file mode 100644 index 0000000..160b6ed --- /dev/null +++ b/.sloptrapignore @@ -0,0 +1,7 @@ +# Paths listed here are not mounted into the container workspace. +# Syntax mirrors .gitignore: comments with '#', glob patterns, and negation with '!'. +# Example patterns: + +# secrets/ +# *.pem +.git/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..78bc6a4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2025 Samuel 'sk4nz' AUBERTIN sk4nz@sk4.nz + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4653b8e --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +SHELL := /bin/sh +PROGRAM := sloptrap + +KNOWN_INSTALL_DIRS := $(HOME)/.local/bin /usr/local/bin /opt/homebrew/bin /opt/local/bin /usr/pkg/bin /usr/bin /usr/local/sbin +AUTO_INSTALL_DIR := $(shell sh -c 'set -e; for dir in $(KNOWN_INSTALL_DIRS); do [ -n "$$dir" ] || continue; if [ -d "$$dir" ] && [ -w "$$dir" ]; then printf "%s" "$$dir"; exit 0; fi; done; fallback="$$HOME/.local/bin"; mkdir -p "$$fallback"; printf "%s" "$$fallback"') +INSTALL_DIR ?= $(AUTO_INSTALL_DIR) +INSTALL_PATH := $(DESTDIR)$(INSTALL_DIR)/$(PROGRAM) + +COLOR_TEXT := \033[38;5;247m +COLOR_HIGHLIGHT := \033[38;5;202m +COLOR_ERROR := \033[38;5;160m +COLOR_COMMENT := \033[38;5;242m +RESET := \033[0m + +PREFIX_TEXT := \033[38;5;247m░\033[0m +PREFIX_HIGHLIGHT := \033[38;5;202m█\033[0m +PREFIX_ERROR := \033[38;5;160m▒\033[0m +PREFIX_COMMENT := \033[38;5;242m▒\033[0m + +.PHONY: help install update uninstall regress + +help: + @printf '%b%bsloptrap%b installer %b\n' '$(PREFIX_TEXT)' '$(COLOR_HIGHLIGHT)' '$(COLOR_TEXT)' '$(RESET)' + @printf '%b%b make install %b[INSTALL_DIR=%s]%b\n' '$(PREFIX_TEXT)' '$(COLOR_HIGHLIGHT)' '$(COLOR_COMMENT)' '$(INSTALL_DIR)' '$(RESET)' + @printf '%b%b make uninstall%b\n' '$(PREFIX_TEXT)' '$(COLOR_HIGHLIGHT)' '$(RESET)' + +install update: $(PROGRAM) + @action="Installing"; if [ -f "$(INSTALL_PATH)" ]; then action="Updating"; fi; \ + printf '%b%b%s%b sloptrap %b(%s)%b\n' '$(PREFIX_COMMENT)' '$(COLOR_HIGHLIGHT)' "$$action" '$(COLOR_TEXT)' '$(COLOR_COMMENT)' '$(INSTALL_PATH)' '$(RESET)' + @install -Dm755 $(PROGRAM) "$(INSTALL_PATH)" + @printf '%b%bSuccess!%b Run it with:%b\n' '$(PREFIX_COMMENT)' '$(COLOR_HIGHLIGHT)' '$(COLOR_TEXT)' '$(RESET)' + @printf '%b%b%b %s /path/to/project%b\n' '$(PREFIX_HIGHLIGHT)' '$(COLOR_HIGHLIGHT)' '\033[1m' '$(PROGRAM)' '$(RESET)' + @printf '%b%bExample files to configure your project:%b\n' '$(PREFIX_TEXT)' '$(COLOR_TEXT)' '$(RESET)' + @printf '%b%b /path/to/project/%b.sloptrap%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(COLOR_TEXT)' '$(RESET)' + @printf '%b%b name=your-project%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(RESET)' + @printf '%b%b default_targets=build run%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(RESET)' + @printf '%b%b packages_extra=make jq%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(RESET)' + @printf '%b%b codex_args=--sandbox workspace-write%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(RESET)' + @printf '%b%b allow_host_network=false%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(RESET)' + @printf '%b%b /path/to/project/%b.sloptrapignore%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(COLOR_TEXT)' '$(RESET)' + @printf '%b%b .git/%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(RESET)' + @printf '%b%b secrets/%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(RESET)' + @printf '%b%b build/output.log%b\n' '$(PREFIX_COMMENT)' '$(COLOR_COMMENT)' '$(RESET)' + +uninstall: + @printf '%b%bRemoving%b %b%s%b\n' '$(PREFIX_COMMENT)' '$(COLOR_TEXT)' '$(COLOR_TEXT)' '$(COLOR_COMMENT)' '$(INSTALL_PATH)' '$(RESET)' + @rm -f "$(INSTALL_PATH)" + +regress: + @./tests/run_tests.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..e941422 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Sloptrap + +Sloptrap runs the OpenAI Codex CLI inside a container with a predictable and locked-down filesystem view. The launcher script (`sloptrap`) resolves the project manifest, builds the support image directly, and starts Codex with the requested target (defaults to `run`). Hardened parsing blocks container escapes via manifest or ignore directives, verifies the downloaded Codex binary, and keeps the runtime environment minimal. + +## Dependencies + +- Podman ≥ 4 (Sloptrap refuses to run without it unless you explicitly override `SLOPTRAP_CONTAINER_ENGINE`). +- GNU `bash`, `curl`, `tar`, `sha256sum`, `realpath` (from GNU coreutils), and `jq` on the host. +- Network access to `https://github.com/openai/codex/releases/` for fetching the Codex binary. +- Enough local disk space to build the container image and cache Codex under `${HOME}/.codex`. + +> Tip: set `SLOPTRAP_CONTAINER_ENGINE=` if you need to override the default Podman requirement (for example, when running inside a CI wrapper that only exposes Docker). + +## Quick Start + +1. Place `sloptrap` somewhere on your PATH/shared drive (the helper Dockerfile and Codex binary are bundled and downloaded automatically). +2. (Optional) Create a project-specific manifest and ignore file: + ```bash + cat > your-project/.sloptrap <<'EOF' + name=your-project + default_targets=run + packages_extra=make + codex_args=--sandbox workspace-write + EOF + + cat > your-project/.sloptrapignore <<'EOF' + .git/ + secrets/ + EOF + ``` +3. Run `./sloptrap your-project`. On the first invocation Sloptrap: + - builds `your-project-sloptrap-image` if missing, + - verifies the Codex binary hash, + - creates `${HOME}/.codex` and runs `login` if credentials are absent. +4. Subsequent calls reuse the image; use `--dry-run` first to inspect the container command that would be executed. +5. Run `make regress` from the repo root to execute the regression suite (ShellCheck plus adversarial harness) before committing changes. + +Use `./sloptrap your-project shell` to enter a troubleshooting shell inside the container or `./sloptrap your-project clean` to remove cached images and state. + +## How It Works + +- The project directory mounts at `/workspace`, and `${HOME}/.codex` mounts at `/codex`. +- `.sloptrapignore` entries (if present) are overlaid by tmpfs (for directories) or empty bind mounts (for files) so Codex cannot read the masked content. Paths are normalised and must remain inside the project tree; attempting to mask parent directories or symlink escapes fails fast. +- 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`. +- The container user matches the host UID/GID (`--userns=keep-id` on Podman or `--user UID:GID` on Docker). +- The runtime environment is fixed to HOME/XDG variables pointing at `/codex`; manifest-controlled environment injection is disabled. + +## `.sloptrap` Manifest Reference + +The manifest is optional. When absent, Sloptrap derives: +- `name = basename(project directory)` +- `default_targets = run` +- `packages_extra = ""` (none) +- `codex_args = "--sandbox workspace-write"` + +Supported keys when the manifest is present: + +| Key | Default | Notes | +| --- | --- | --- | +| `name` | project directory name | Must match `^[A-Za-z0-9_.-]+$`. Used for image/container naming. | +| `default_targets` | `run` | Space-separated targets invoked when none are provided on the CLI. | +| `packages_extra` | *empty* | Additional Debian packages installed during `docker/podman build`. Tokens must be alphanumeric plus `+.-`. | +| `codex_args` | `--sandbox workspace-write` | Passed verbatim to the Codex CLI entrypoint. Tokens are shell-split, so quote values with spaces (e.g., `--profile security-audit`). | +| `allow_host_network` | `false` | `true` opts into `--network host`; keep `false` unless the project absolutely requires direct access to host-local services. | +`codex_args` are appended after the default sandbox flag, and Sloptrap refuses to run if the resulting `--sandbox` mode is anything other than `workspace-write` or `workspace-read-only`. + +Values containing `$`, `` ` ``, or newlines are rejected to prevent command injection. Setting illegal keys or malformed values aborts the run before containers start. + +### `.sloptrapignore` + +- Parsed using gitignore-style globbing with support for `!negation`. +- Entries must stay within the project root after resolving symlinks; attempts to reference `.`/`..`, absolute paths, or symlink escapes raise errors. +- Directory matches become `--mount type=tmpfs,target=/workspace/`. File matches bind to empty files within `.sloptrap-ignores/session-/`. +- The helper directory is removed automatically on exit or during `./sloptrap clean`. + +## CLI Reference + +``` +./sloptrap [--dry-run] [--print-config] [target ...] +``` + +Options: + +- `--dry-run` — print the container/engine commands that would run without executing them. +- `--print-config` — output the resolved manifest values, defaults, and ignore list. +- `-h, --help` — display usage. +- `--` — stop option parsing; remaining arguments are treated as targets. + +Behaviour: + +- Missing manifests are treated as default configuration. +- `SLOPTRAP_CONTAINER_ENGINE` overrides engine auto-detection. +- If `${HOME}/.codex/auth.json` is absent, 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. + +### Regression Suite + +- `make regress` (or `tests/run_tests.sh`) runs `shellcheck` against `sloptrap` and then executes every scenario in `tests/run_tests.sh`, including the container build path check. +- The suite must pass cleanly; ShellCheck diagnostics or scenario regressions cause a non-zero exit and should be fixed before shipping changes. + +## Built-in Targets + +Targets are supplied after the code directory (or via `default_targets` in the manifest). When omitted, Sloptrap defaults to `run`. + +| Target | Description | +| --- | --- | +| `build` | Download Codex (if missing), verify SHA-256, and build the container image. | +| `build-if-missing` | No-op when the image already exists; otherwise delegates to `build`. | +| `rebuild` | Rebuild the image from scratch (`--no-cache`). | +| `run` | Default goal. Runs the container with Codex as entrypoint and passes `codex_args`. | +| `login` | Starts Codex in login mode to bootstrap `${HOME}/.codex`. | +| `shell` | Launches `/bin/bash` inside the container for debugging. | +| `stop` | Best-effort stop of the running container (if any). | +| `clean` | Removes `.sloptrap-ignores`, deletes the container/image, and stops the container if necessary. | + +The launcher executes targets sequentially, so `./sloptrap repo build run` performs an explicit rebuild before invoking Codex. Extra targets may be added in the future; unknown names fail fast. + +## Execution Environment + +- Container engine: Podman or Docker 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`. +- 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`. + +## 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. +- **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. +- **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. + +These constraints focus on limiting host data exposure to the Codex session while acknowledging that any material introduced into the context window may leave the environment through the upstream API. diff --git a/sloptrap b/sloptrap new file mode 100755 index 0000000..599369f --- /dev/null +++ b/sloptrap @@ -0,0 +1,1248 @@ +#!/usr/bin/env bash +# sloptrap +set -euo pipefail + +require_cmd() { command -v "$1" >/dev/null 2>&1 || error "'$1' is required"; } +for c in curl tar sha256sum realpath jq; do require_cmd "$c"; done + +COLOR_TEXT=$'\033[38;5;247m' +COLOR_HIGHLIGHT=$'\033[38;5;202m' +COLOR_ERROR=$'\033[38;5;160m' +COLOR_COMMENT=$'\033[38;5;242m' +RESET=$'\033[0m' +BOLD=$'\033[1m' + +PREFIX_TEXT=$'\033[38;5;247m░\033[0m ' +PREFIX_HIGHLIGHT=$'\033[38;5;202m█\033[0m ' +PREFIX_ERROR=$'\033[38;5;160m▒\033[0m ' +PREFIX_COMMENT=$'\033[38;5;242m▒\033[0m ' + +print_styled() { + local color=$1 + local prefix=$2 + local fmt=$3 + shift 3 + printf '%s' "$prefix" + printf '%b' "$color" + if (($# > 0)); then + local -a colored_args=() + local arg + for arg in "$@"; do + colored_args+=("$COLOR_HIGHLIGHT$arg$color") + done + printf "$fmt" "${colored_args[@]}" + else + printf '%b' "$fmt" + fi + printf '%b' "$RESET" +} + +print_styled_err() { + local color=$1 + local prefix=$2 + local fmt=$3 + shift 3 + printf '%s' "$prefix" >&2 + printf '%b' "$color" >&2 + if (($# > 0)); then + local -a colored_args=() + local arg + for arg in "$@"; do + colored_args+=("$COLOR_HIGHLIGHT$arg$color") + done + printf "$fmt" "${colored_args[@]}" >&2 + else + printf '%b' "$fmt" >&2 + fi + printf '%b' "$RESET" >&2 +} + +info_line() { + print_styled "$COLOR_TEXT" "$PREFIX_TEXT" "$@" +} + +highlight_line() { + print_styled "$COLOR_HIGHLIGHT" "$PREFIX_TEXT" "$@" +} + +status_line() { + print_styled "$COLOR_COMMENT" "$PREFIX_TEXT" "$@" +} + +comment_line() { + print_styled "$COLOR_COMMENT" "$PREFIX_COMMENT" "$@" +} + +warn_line() { + print_styled_err "$COLOR_HIGHLIGHT" "$PREFIX_HIGHLIGHT" "$@" +} + +error_line() { + print_styled_err "$COLOR_ERROR" "$PREFIX_ERROR" "$@" +} + +print_banner() { + printf '%b' "${COLOR_HIGHLIGHT}${BOLD}" + cat <<'EOF' + ██████ ██▓ ▒█████ ██▓███ ▄▄▄█████▓ ██▀███ ▄▄▄ ██▓███ +▒██ ▒ ▓██▒ ▒██▒ ██▒▓██░ ██▒▓ ██▒ ▓▒▓██ ▒ ██▒▒████▄ ▓██░ ██▒ +░ ▓██▄ ▒██░ ▒██░ ██▒▓██░ ██▓▒▒ ▓██░ ▒░▓██ ░▄█ ▒▒██ ▀█▄ ▓██░ ██▓▒ + ▒ ██▒▒██░ ▒██ ██░▒██▄█▓▒ ▒░ ▓██▓ ░ ▒██▀▀█▄ ░██▄▄▄▄██ ▒██▄█▓▒ ▒ +▒██████▒▒░██████▒░ ████▓▒░▒██▒ ░ ░ ▒██▒ ░ ░██▓ ▒██▒ ▓█ ▓██▒▒██▒ ░ ░ +▒ ▒▓▒ ▒ ░░ ▒░▓ ░░ ▒░▒░▒░ ▒▓▒░ ░ ░ ▒ ░░ ░ ▒▓ ░▒▓░ ▒▒ ▓▒█░▒▓▒░ ░ ░ +░ ░▒ ░ ░░ ░ ▒ ░ https://git.sk4.nz/sk4nz/sloptrap ░ ▒ ▒▒ ░░▒ ░ +░ ░ ░ ░ ░ ░ ░ ░ ▒ ░░ ░ ░░ ░ ░ ▒ ░░ + ░ ░ ░ ░ ░ ░ ░ ░ +EOF + printf '%b' "$RESET" +} + +MANIFEST_BASENAME=".sloptrap" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VALID_NAME_REGEX='^[A-Za-z0-9_.-]+$' +DEFAULT_CODEX_ARGS=(--sandbox workspace-write) + +usage() { + print_banner + info_line "Usage: %s [options] [target ...]\n" "$0" + info_line "Options:\n" + comment_line " --dry-run Show planned container command(s) and exit\n" + comment_line " --print-config Display resolved manifest values\n" + comment_line " -h, --help Show this message\n" + info_line "\n" + comment_line "Each project supplies configuration via a %s file in its root.\n" "$MANIFEST_BASENAME" + info_line "Example manifest entries:\n" + comment_line " name=my-project\n" + comment_line " packages_extra=kubectl helm\n" + comment_line " default_targets=run\n" + comment_line " codex_args=--sandbox workspace-write\n" +} + +error() { + error_line "error: %s\n" "$1" + exit 1 +} + +warn() { + warn_line "warning: %s\n" "$1" +} + +trim() { + local value=$1 + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +declare -A MANIFEST=() +declare -a SLOPTRAP_IGNORE_ENTRIES=() +declare -a IGNORE_MOUNT_ARGS=() +MANIFEST_PRESENT=false + +CURRENT_IGNORE_FILE="" +CONTAINER_ENGINE="" +CODEX_HOME_HOST="" +CODEX_HOME_BOOTSTRAP=false +NEED_LOGIN=false +IGNORE_STUB_BASE="" +IGNORE_HELPER_ROOT="" +ALLOW_HOST_NETWORK=false + +declare -a SLOPTRAP_TEMP_PATHS=() + +register_temp_path() { + SLOPTRAP_TEMP_PATHS+=("$1") +} + +cleanup_temp_paths() { + local path + for path in "${SLOPTRAP_TEMP_PATHS[@]}"; do + [[ -n ${path:-} ]] || continue + rm -rf "$path" >/dev/null 2>&1 || true + done +} + +cleanup_ignore_stub_dir() { + local helper_root=${IGNORE_HELPER_ROOT:-} + local stub_base=${IGNORE_STUB_BASE:-} + [[ -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 + warn "failed to resolve helper root '$helper_root' during cleanup" + return 0 + fi + if ! resolved_stub=$(realpath -m "$stub_base") 2>/dev/null; then + warn "failed to resolve helper stub '$stub_base' during cleanup" + return 0 + fi + case "$resolved_stub" in + "$resolved_helper"/session-*|"$resolved_helper"/fallback) + rm -rf "$resolved_stub" + ;; + *) + warn "refusing to remove unexpected helper path '$resolved_stub'" + ;; + esac +} + +sloptrap_exit_trap() { + cleanup_temp_paths + cleanup_ignore_stub_dir +} + +trap sloptrap_exit_trap EXIT INT TERM HUP + +create_temp_dir() { + local label=${1:-tmp} + local template="${TMPDIR:-/tmp}/sloptrap.${label}.XXXXXXXX" + local dir + if ! dir=$(mktemp -d "$template"); then + error "failed to create temporary directory under ${TMPDIR:-/tmp}" + fi + register_temp_path "$dir" + printf '%s' "$dir" +} + +write_embedded_dockerfile() { + cat <<'EOF' +# Dockerfile.sloptrap +ARG BASE_IMAGE=debian:trixie-slim +FROM ${BASE_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive + +ARG BASE_PACKAGES="curl bash ca-certificates libstdc++6 ripgrep xxd file" +ARG EXTRA_PACKAGES="" +RUN apt-get update \ + && apt-get install -y --no-install-recommends apt-utils ${BASE_PACKAGES} ${EXTRA_PACKAGES} \ + && rm -rf /var/lib/apt/lists/* + +ARG CODEX_UID=1337 +ARG CODEX_GID=1337 +RUN groupadd --gid ${CODEX_GID} sloptrap \ + && useradd --create-home --home-dir /home/sloptrap \ + --gid sloptrap --uid ${CODEX_UID} --shell /bin/bash sloptrap + +ARG CODEX_BIN=codex +ARG CODEX_CONF=config/config.toml +COPY ${CODEX_BIN} /usr/local/bin/codex + +RUN chown -R sloptrap:sloptrap /home/sloptrap +USER sloptrap + +WORKDIR /workspace + +ENV SHELL=/bin/bash HOME=/home/sloptrap +ENTRYPOINT ["codex"] +EOF +} + +populate_dockerfile() { + local destination=$1 + mkdir -p "$(dirname "$destination")" + if [[ -n $SLOPTRAP_DOCKERFILE_SOURCE ]]; then + if [[ ! -f $SLOPTRAP_DOCKERFILE_SOURCE ]]; then + error "container recipe '$SLOPTRAP_DOCKERFILE_SOURCE' not found" + fi + cp "$SLOPTRAP_DOCKERFILE_SOURCE" "$destination" + else + write_embedded_dockerfile >"$destination" + fi +} + +validate_basename() { + local name=$1 + [[ $name =~ ^[A-Za-z0-9._+-]+$ ]] || error "invalid basename '$name'" +} + +prepare_build_context() { + if [[ -n $SLOPTRAP_BUILD_CONTEXT && -d $SLOPTRAP_BUILD_CONTEXT ]]; then + return 0 + fi + SLOPTRAP_BUILD_CONTEXT=$(create_temp_dir "context") + SLOPTRAP_DOCKERFILE_PATH="$SLOPTRAP_BUILD_CONTEXT/Dockerfile.sloptrap" + populate_dockerfile "$SLOPTRAP_DOCKERFILE_PATH" + validate_basename "$SLOPTRAP_CODEX_BIN_NAME" + CODEX_BIN_PATH="$SLOPTRAP_BUILD_CONTEXT/$SLOPTRAP_CODEX_BIN_NAME" +} + +select_codex_home() { + local preferred="$HOME/.codex" + if [[ -e $preferred && ! -d $preferred ]]; then + 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_HOME_BOOTSTRAP=false + else + CODEX_HOME_BOOTSTRAP=true + fi +} + +assert_path_within_code_dir() { + local candidate=$1 + local resolved + resolved=$(realpath -m "$candidate") + if [[ $resolved != "$CODE_DIR" && $resolved != "$CODE_DIR/"* ]]; then + error "path '$candidate' escapes project root '$CODE_DIR'" + fi +} + +ensure_ignore_helper_root() { + local helper_root="$CODE_DIR/.sloptrap-ignores" + if [[ -L $helper_root ]]; then + error "$helper_root: helper directory may not be a symlink" + fi + if [[ -e $helper_root && ! -d $helper_root ]]; then + error "$helper_root: expected a directory" + fi + assert_path_within_code_dir "$helper_root" + IGNORE_HELPER_ROOT="$helper_root" +} + +sanitize_ignore_rel() { + local rel=$1 + local original=$rel + local source=${CURRENT_IGNORE_FILE:-$MANIFEST_PATH} + while [[ $rel == ./* ]]; do + rel=${rel:2} + done + if [[ -z $rel || $rel == "." ]]; then + error "$source: .sloptrapignore entry '$original' may not target the project root" + fi + if [[ ${rel:0:1} == "/" ]]; then + error "$source: .sloptrapignore entry '$original' must be relative" + fi + local IFS='/' + local -a segments=() + read -r -a segments <<< "$rel" + if [[ ${#segments[@]} -eq 0 ]]; then + error "$source: .sloptrapignore entry '$original' is invalid" + fi + local segment + for segment in "${segments[@]}"; do + if [[ -z $segment || $segment == "." || $segment == ".." ]]; then + error "$source: .sloptrapignore entry '$original' uses disallowed path components" + fi + done + if [[ ${rel//$'\n'/} != "$rel" || ${rel//$'\r'/} != "$rel" ]]; then + error "$source: .sloptrapignore entry '$original' contains control characters" + fi + local resolved + resolved=$(realpath -m "$CODE_DIR/$rel") + if [[ $resolved != "$CODE_DIR" && $resolved != "$CODE_DIR/"* ]]; then + error "$source: .sloptrapignore entry '$original' resolves outside the project root" + fi + printf '%s' "$rel" +} + +resolve_sloptrap_ignore() { + local root=$1 + local ignore_file="$root/.sloptrapignore" + SLOPTRAP_IGNORE_ENTRIES=() + [[ -f $ignore_file ]] || return 0 + + CURRENT_IGNORE_FILE="$ignore_file" + local -a patterns=() + local line trimmed + while IFS= read -r line || [[ -n $line ]]; do + trimmed="$(trim "$line")" + [[ -z $trimmed ]] && continue + [[ ${trimmed:0:1} == "#" ]] && continue + patterns+=("$trimmed") + done <"$ignore_file" + [[ ${#patterns[@]} -gt 0 ]] || return 0 + + local -A selected=() + local pattern negate anchored dir_only raw had_match + local -a matches + local match rel + local prev_gs prev_ng prev_dg + prev_gs=$(shopt -p globstar 2>/dev/null || true) + prev_ng=$(shopt -p nullglob 2>/dev/null || true) + prev_dg=$(shopt -p dotglob 2>/dev/null || true) + shopt -s globstar nullglob dotglob + for pattern in "${patterns[@]}"; do + negate=false + anchored=false + dir_only=false + raw="$pattern" + had_match=false + if [[ ${raw:0:1} == "!" ]]; then + negate=true + raw=${raw:1} + fi + if [[ ${raw:0:1} == "/" ]]; then + anchored=true + raw=${raw:1} + fi + if [[ ${raw: -1} == "/" ]]; then + dir_only=true + raw=${raw%/} + fi + [[ -n $raw ]] || raw="." + matches=() + if $anchored; then + while IFS= read -r match; do + matches+=("$root/$match") + done < <( + cd "$root" && { compgen -G "$raw" || true; } + ) + else + while IFS= read -r match; do + matches+=("$root/$match") + done < <( + cd "$root" && { compgen -G "$raw" || true; } + ) + while IFS= read -r match; do + matches+=("$root/$match") + done < <( + cd "$root" && { compgen -G "**/$raw" || true; } + ) + fi + local -A seen=() + for match in "${matches[@]}"; do + [[ -e $match ]] || continue + if [[ $match != "$root" && $match != $root/* ]]; then + continue + fi + rel=${match#"$root"/} + [[ -n $rel ]] || rel="." + if $dir_only && [[ ! -d $match ]]; then + continue + fi + if [[ -n ${seen[$rel]-} ]]; then + continue + fi + seen[$rel]=1 + rel=$(sanitize_ignore_rel "$rel") + had_match=true + if $negate; then + unset 'selected[$rel]' + else + if [[ -d $match ]]; then + selected[$rel]="dir" + else + selected[$rel]="file" + fi + fi + done + if ! $had_match && ! $negate; then + warn "${CURRENT_IGNORE_FILE:-$MANIFEST_PATH}: .sloptrapignore entry '$pattern' matched no files" + fi + done + eval "$prev_gs" + eval "$prev_ng" + eval "$prev_dg" + + if [[ ${#selected[@]} -eq 0 ]]; then + CURRENT_IGNORE_FILE="" + return 0 + fi + SLOPTRAP_IGNORE_ENTRIES=() + for rel in "${!selected[@]}"; do + SLOPTRAP_IGNORE_ENTRIES+=("${selected[$rel]}"$'\t'"$rel") + done + mapfile -t SLOPTRAP_IGNORE_ENTRIES < <(printf '%s\n' "${SLOPTRAP_IGNORE_ENTRIES[@]}" | sort) + CURRENT_IGNORE_FILE="" +} + +escape_mount_value() { + local value=$1 + value=${value//\\/\\\\} + value=${value//,/\\,} + value=${value//=/\\=} + printf '%s' "$value" +} + +prepare_ignore_mounts() { + IGNORE_MOUNT_ARGS=() + [[ ${#SLOPTRAP_IGNORE_ENTRIES[@]} -gt 0 ]] || return 0 + local container_root="${SLOPTRAP_WORKDIR:-/workspace}" + if [[ $container_root != "/" ]]; then + container_root="${container_root%/}" + [[ -n $container_root ]] || container_root="/" + fi + local entry type rel target host_path stub_base target_mount host_path_mount + stub_base="$IGNORE_STUB_BASE" + assert_path_within_code_dir "$stub_base" + rm -rf "$stub_base" 2>/dev/null || true + if ! mkdir -p "$stub_base/files" 2>/dev/null; then + stub_base="$CODE_DIR/.sloptrap-ignores/fallback" + assert_path_within_code_dir "$stub_base" + rm -rf "$stub_base" 2>/dev/null || true + mkdir -p "$stub_base/files" + fi + IGNORE_STUB_BASE="$stub_base" + for entry in "${SLOPTRAP_IGNORE_ENTRIES[@]}"; do + type=${entry%%$'\t'*} + rel=${entry#*$'\t'} + if [[ $container_root == "/" ]]; then + target="/$rel" + else + target="$container_root/$rel" + fi + target_mount=$(escape_mount_value "$target") + case "$type" in + dir) + IGNORE_MOUNT_ARGS+=("--mount" "type=tmpfs,target=$target_mount") + ;; + file) + host_path="$stub_base/files/$rel" + assert_path_within_code_dir "$host_path" + mkdir -p "$(dirname "$host_path")" + : > "$host_path" + host_path_mount=$(escape_mount_value "$host_path") + IGNORE_MOUNT_ARGS+=("--mount" "type=bind,source=$host_path_mount,target=$target_mount,readonly") + ;; + esac + done +} + +ensure_safe_for_make() { + local key=$1 + local value=$2 + if [[ $value == *'$'* || $value == *'`'* ]]; then + error "$MANIFEST_PATH: value for '$key' must not contain \$ or \` characters" + fi + if [[ $value == *$'\n'* ]]; then + error "$MANIFEST_PATH: value for '$key' must not span multiple lines" + fi +} + +validate_package_list() { + local key=$1 + local raw=$2 + [[ -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'" + fi + done +} + +detect_container_engine() { + local override=${SLOPTRAP_CONTAINER_ENGINE-} + if [[ -n $override ]]; then + local engine="${override,,}" + if ! command -v "$engine" >/dev/null 2>&1; then + error "container engine '$engine' not found in PATH" + fi + printf '%s' "$engine" + return 0 + fi + if command -v podman >/dev/null 2>&1; then + printf 'podman' + return 0 + fi + error "podman is required but was not found in PATH; set SLOPTRAP_CONTAINER_ENGINE to override explicitly" +} + +parse_manifest() { + local manifest_path=$1 + local line key value + while IFS= read -r line || [[ -n $line ]]; do + line="$(trim "$line")" + [[ -z $line ]] && continue + [[ ${line:0:1} == "#" ]] && continue + if [[ $line != *"="* ]]; then + error "$manifest_path: expected KEY=VALUE entry (got '$line')" + fi + key="$(trim "${line%%=*}")" + value="$(trim "${line#*=}")" + if [[ -z $key ]]; then + error "$manifest_path: blank key in line '$line'" + fi + if [[ ( ${value:0:1} == '"' && ${value: -1} == '"' ) || ( ${value:0:1} == "'" && ${value: -1} == "'" ) ]]; then + value="${value:1:-1}" + fi + if [[ $key == make.* ]]; then + error "$manifest_path: make.* overrides are no longer supported; use packages_extra instead" + fi + MANIFEST["$key"]=$value + done < "$manifest_path" +} + +print_config() { + local manifest_path=$1 + info_line "manifest_path=%s\n" "$manifest_path" + info_line "manifest_present=%s\n" "$MANIFEST_PRESENT" + info_line "project_name=%s\n" "$PROJECT_NAME" + info_line "project_dir=%s\n" "$CODE_DIR" + info_line "resolved_targets=%s\n" "${DEFAULT_TARGETS[*]}" + 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_bootstrap=%s\n" "$CODEX_HOME_BOOTSTRAP" + 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" + if [[ ${#SLOPTRAP_IGNORE_ENTRIES[@]} -gt 0 ]]; then + local ignore_paths + ignore_paths=$(printf '%s ' "${SLOPTRAP_IGNORE_ENTRIES[@]}") + ignore_paths=${ignore_paths% } + info_line "ignore_paths=%s\n" "$ignore_paths" + else + info_line "ignore_paths=\n" + fi + if [[ -n $SLOPTRAP_DOCKERFILE_SOURCE ]]; then + info_line "dockerfile_source=%s\n" "$SLOPTRAP_DOCKERFILE_SOURCE" + else + info_line "dockerfile_source=embedded\n" + fi + for key in "${!MANIFEST[@]}"; do + info_line "%s=%s\n" "$key" "${MANIFEST[$key]}" + done +} + +declare -a CONTAINER_SHARED_OPTS=() +declare -a BASE_CONTAINER_CMD=() +SLOPTRAP_IMAGE_NAME="" +SLOPTRAP_CONTAINER_NAME="" +SLOPTRAP_DOCKERFILE_PATH="" +SLOPTRAP_BUILD_CONTEXT="" +SLOPTRAP_DOCKERFILE_SOURCE="" +CODEX_BIN_PATH="" +SLOPTRAP_SHARED_DIR_ABS="" +SLOPTRAP_PACKAGES_BASE="" +SLOPTRAP_PACKAGES_EXTRA_RESOLVED="" +SLOPTRAP_CODEX_BIN_NAME="" +SLOPTRAP_CODEX_URL="" +SLOPTRAP_CODEX_HOME_CONT="" +SLOPTRAP_SECURITY_OPTS_EXTRA="" +SLOPTRAP_VOLUME_LABEL="" +SLOPTRAP_WORKDIR=${SLOPTRAP_WORKDIR-} +SLOPTRAP_NETWORK_NAME="" +SLOPTRAP_LIMITS_PID="" +SLOPTRAP_LIMITS_RAM="" +SLOPTRAP_LIMITS_SWP="" +SLOPTRAP_LIMITS_SHM="" +SLOPTRAP_LIMITS_CPU="" +SLOPTRAP_TMPFS_PATHS="" +SLOPTRAP_ROOTFS_READONLY="" + +get_env_default() { + local var=$1 + local default=$2 + local value + if value=$(printenv "$var" 2>/dev/null); then + printf '%s' "$value" + else + printf '%s' "$default" + fi +} + +resolve_container_workdir() { + if [[ -z ${SLOPTRAP_WORKDIR:-} ]]; then + SLOPTRAP_WORKDIR=$(get_env_default "SLOPTRAP_WORKDIR" "/workspace") + fi + [[ -n $SLOPTRAP_WORKDIR ]] || SLOPTRAP_WORKDIR="/workspace" + if [[ $SLOPTRAP_WORKDIR == "/" ]]; then + error "SLOPTRAP_WORKDIR may not be '/'; use a subdirectory like /workspace" + fi +} + +print_command() { + local rendered="" + if [[ $# -gt 0 ]]; then + rendered=$(printf '%q ' "$@") + rendered=${rendered% } + fi + comment_line "%s\n" "$rendered" +} + +run_or_print() { + if $DRY_RUN; then + print_command "$@" + return 0 + fi + "$@" +} + +ensure_codex_home_dir() { + if [[ -d $CODEX_HOME_HOST ]]; then + return 0 + fi + if $DRY_RUN; then + print_command mkdir -p "$CODEX_HOME_HOST" + return 0 + fi + mkdir -p "$CODEX_HOME_HOST" +} + +fetch_latest_codex_digest() { + local api_url="https://api.github.com/repos/openai/codex/releases/latest" + if ! command -v jq >/dev/null 2>&1; then + error "jq is required to verify the Codex binary digest" + fi + local response + if ! response=$(curl -fsSL "$api_url"); then + 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) + if [[ -z $digest_line || $digest_line == "null" ]]; then + error "failed to resolve Codex digest from GitHub response" + fi + digest_line=${digest_line#sha256:} + printf '%s' "$digest_line" +} + +ensure_codex_binary() { + prepare_build_context + if [[ -x $CODEX_BIN_PATH ]]; then + return 0 + fi + 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" + return 0 + fi + if ! curl -Lso "$tmp_archive" "$SLOPTRAP_CODEX_URL"; then + rm -rf "$download_dir" + error "failed to download Codex binary from '$SLOPTRAP_CODEX_URL'" + fi + local expected_digest + expected_digest=$(fetch_latest_codex_digest) + if ! printf "%s %s\n" "$expected_digest" "$tmp_archive" | sha256sum -c - >/dev/null 2>&1; then + 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 + rm -rf "$download_dir" + error "failed to extract Codex binary" + fi + rm -rf "$download_dir" + chmod +x "$CODEX_BIN_PATH" +} + +ensure_safe_sandbox() { + local -a args=("$@") + local sandbox_mode="" + local i=0 + while [[ $i -lt ${#args[@]} ]]; do + if [[ ${args[$i]} == "--sandbox" ]]; then + if (( i + 1 >= ${#args[@]} )); then + error "$MANIFEST_PATH: '--sandbox' flag requires a mode (workspace-write or workspace-read-only)" + fi + sandbox_mode="${args[$((i + 1))]}" + fi + ((i+=1)) + done + if [[ -z $sandbox_mode ]]; then + error "$MANIFEST_PATH: codex_args must include '--sandbox ' (workspace-write or workspace-read-only)" + fi + case "$sandbox_mode" in + workspace-write|workspace-read-only) + ;; + *) + error "$MANIFEST_PATH: sandbox mode '$sandbox_mode' is not allowed (expected workspace-write or workspace-read-only)" + ;; + esac +} + +normalize_package_list() { + local raw=$1 + raw="$(trim "$raw")" + [[ -z $raw ]] && return 0 + local -a tokens=() + read -r -a tokens <<< "$raw" + printf '%s' "${tokens[*]}" +} + +prepare_container_runtime() { + resolve_container_workdir + SLOPTRAP_PACKAGES_EXTRA_RESOLVED=$PACKAGES_EXTRA + SLOPTRAP_SHARED_DIR_ABS=$(get_env_default "SLOPTRAP_SHARED_DIR" "$CODE_DIR") + if [[ ! -d $SLOPTRAP_SHARED_DIR_ABS ]]; then + error "shared directory '$SLOPTRAP_SHARED_DIR_ABS' does not exist" + fi + SLOPTRAP_SHARED_DIR_ABS="$(cd "$SLOPTRAP_SHARED_DIR_ABS" && pwd -P)" + + local dockerfile_override + dockerfile_override=$(get_env_default "SLOPTRAP_DOCKERFILE_PATH" "") + if [[ -n $dockerfile_override ]]; then + if [[ $dockerfile_override != /* ]]; then + dockerfile_override="$SCRIPT_DIR/$dockerfile_override" + fi + if [[ ! -f $dockerfile_override ]]; then + error "container recipe '$dockerfile_override' not found" + fi + SLOPTRAP_DOCKERFILE_SOURCE="$dockerfile_override" + elif [[ -f "$SCRIPT_DIR/Dockerfile.sloptrap" ]]; then + SLOPTRAP_DOCKERFILE_SOURCE="$SCRIPT_DIR/Dockerfile.sloptrap" + else + SLOPTRAP_DOCKERFILE_SOURCE="" + fi + + SLOPTRAP_PACKAGES_BASE=$(get_env_default "SLOPTRAP_PACKAGES" "curl bash ca-certificates libstdc++6 git ripgrep xxd file") + 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") + 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") + SLOPTRAP_CODEX_GID=$(get_env_default "SLOPTRAP_CODEX_GID" "1337") + SLOPTRAP_SECURITY_OPTS_EXTRA=$(get_env_default "SLOPTRAP_SECURITY_OPTS_EXTRA" "") + local default_network="bridge" + if [[ $CONTAINER_ENGINE == "podman" ]]; then + if command -v slirp4netns >/dev/null 2>&1; then + default_network="slirp4netns" + else + warn "podman detected but 'slirp4netns' is missing; falling back to 'bridge' networking" + fi + fi + SLOPTRAP_NETWORK_NAME=$(get_env_default "SLOPTRAP_NETWORK_NAME" "$default_network") + if $ALLOW_HOST_NETWORK; then + SLOPTRAP_NETWORK_NAME="host" + elif [[ $SLOPTRAP_NETWORK_NAME == "host" ]]; then + error "$MANIFEST_PATH: host networking requires allow_host_network=true" + fi + SLOPTRAP_LIMITS_PID=$(get_env_default "SLOPTRAP_LIMITS_PID" "1024") + SLOPTRAP_LIMITS_RAM=$(get_env_default "SLOPTRAP_LIMITS_RAM" "1024m") + SLOPTRAP_LIMITS_SWP=$(get_env_default "SLOPTRAP_LIMITS_SWP" "1024m") + SLOPTRAP_LIMITS_SHM=$(get_env_default "SLOPTRAP_LIMITS_SHM" "1024m") + SLOPTRAP_LIMITS_CPU=$(get_env_default "SLOPTRAP_LIMITS_CPU" "8") + SLOPTRAP_TMPFS_PATHS=$(get_env_default "SLOPTRAP_TMPFS_PATHS" "/tmp:exec /run /run/lock") + 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") + + local -a network_opts=(--network "$SLOPTRAP_NETWORK_NAME" --init) + local -a security_opts=(--cap-drop=ALL --security-opt no-new-privileges) + if [[ -n $SLOPTRAP_SECURITY_OPTS_EXTRA ]]; then + local -a extra_opts=() + read -r -a extra_opts <<< "$SLOPTRAP_SECURITY_OPTS_EXTRA" + security_opts+=("${extra_opts[@]}") + fi + local -a resource_opts=( + --pids-limit "$SLOPTRAP_LIMITS_PID" + --memory "$SLOPTRAP_LIMITS_RAM" + --memory-swap "$SLOPTRAP_LIMITS_SWP" + --shm-size "$SLOPTRAP_LIMITS_SHM" + --cpus "$SLOPTRAP_LIMITS_CPU" + ) + local -a tmpfs_opts=() + if [[ -n $SLOPTRAP_TMPFS_PATHS ]]; then + local -a tmpfs_paths=() + read -r -a tmpfs_paths <<< "$SLOPTRAP_TMPFS_PATHS" + local path + for path in "${tmpfs_paths[@]}"; do + tmpfs_opts+=(--tmpfs "$path") + done + fi + + local rootfs_flag=() + case "${SLOPTRAP_ROOTFS_READONLY,,}" in + 1|true|yes) + rootfs_flag=(--read-only) + ;; + esac + + if [[ $CONTAINER_ENGINE == "podman" ]]; then + SLOPTRAP_VOLUME_LABEL=":Z" + else + SLOPTRAP_VOLUME_LABEL="" + fi + + 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" + ) + + local -a env_args=( + -e "HOME=$SLOPTRAP_CODEX_HOME_CONT" + -e "XDG_CONFIG_HOME=$SLOPTRAP_CODEX_HOME_CONT/config" + -e "XDG_CACHE_HOME=$SLOPTRAP_CODEX_HOME_CONT/cache" + -e "XDG_STATE_HOME=$SLOPTRAP_CODEX_HOME_CONT/state" + -e "CODEX_HOME=$SLOPTRAP_CODEX_HOME_CONT" + ) + + local uid gid + uid=$(id -u) + gid=$(id -g) + local -a user_opts=("--user" "$uid:$gid") + if [[ $CONTAINER_ENGINE == "podman" ]]; then + user_opts=(--userns="keep-id:uid=$uid,gid=$gid" "${user_opts[@]}") + fi + + CONTAINER_SHARED_OPTS=( + "${network_opts[@]}" + "${security_opts[@]}" + "${resource_opts[@]}" + "${rootfs_flag[@]}" + "${tmpfs_opts[@]}" + "${volume_opts[@]}" + "${env_args[@]}" + "${IGNORE_MOUNT_ARGS[@]}" + "${user_opts[@]}" + -w "$SLOPTRAP_WORKDIR" + ) + + BASE_CONTAINER_CMD=( + "$CONTAINER_ENGINE" run --rm -it + --name "$SLOPTRAP_CONTAINER_NAME" + "${CONTAINER_SHARED_OPTS[@]}" + ) +} + +build_image() { + ensure_codex_binary + print_banner + local extra_packages_arg + extra_packages_arg=$(normalize_package_list "$SLOPTRAP_PACKAGES_EXTRA_RESOLVED") + if ! $DRY_RUN; then + status_line "Building %s\n" "$SLOPTRAP_IMAGE_NAME" + fi + local -a cmd=( + "$CONTAINER_ENGINE" build --quiet + -t "$SLOPTRAP_IMAGE_NAME" + -f "$SLOPTRAP_DOCKERFILE_PATH" + --build-arg "BASE_PACKAGES=$SLOPTRAP_PACKAGES_BASE" + --build-arg "CODEX_BIN=$SLOPTRAP_CODEX_BIN_NAME" + --build-arg "CODEX_UID=$SLOPTRAP_CODEX_UID" + --build-arg "CODEX_GID=$SLOPTRAP_CODEX_GID" + ) + if [[ -n $extra_packages_arg ]]; then + cmd+=(--build-arg "EXTRA_PACKAGES=$extra_packages_arg") + fi + cmd+=("$SLOPTRAP_BUILD_CONTEXT") + if $DRY_RUN; then + run_or_print "${cmd[@]}" + return + fi + + local build_output + if ! build_output=$("${cmd[@]}"); then + return 1 + fi + build_output=$(trim "$build_output") + if [[ -n $build_output ]]; then + comment_line "Image %s\n" "$build_output" + fi +} + +rebuild_image() { + ensure_codex_binary + if ! $DRY_RUN; then + status_line "Rebuilding %s (no cache)\n" "$SLOPTRAP_IMAGE_NAME" + fi + local extra_packages_arg + extra_packages_arg=$(normalize_package_list "$SLOPTRAP_PACKAGES_EXTRA_RESOLVED") + local -a cmd=( + "$CONTAINER_ENGINE" build --no-cache + -t "$SLOPTRAP_IMAGE_NAME" + -f "$SLOPTRAP_DOCKERFILE_PATH" + --build-arg "BASE_PACKAGES=$SLOPTRAP_PACKAGES_BASE" + --build-arg "CODEX_BIN=$SLOPTRAP_CODEX_BIN_NAME" + --build-arg "CODEX_UID=$SLOPTRAP_CODEX_UID" + --build-arg "CODEX_GID=$SLOPTRAP_CODEX_GID" + ) + if [[ -n $extra_packages_arg ]]; then + cmd+=(--build-arg "EXTRA_PACKAGES=$extra_packages_arg") + fi + cmd+=("$SLOPTRAP_BUILD_CONTEXT") + if $DRY_RUN; then + run_or_print "${cmd[@]}" + return + fi + + local build_output + if ! build_output=$("${cmd[@]}"); then + return 1 + fi + build_output=$(trim "$build_output") + if [[ -n $build_output ]]; then + comment_line "Image %s\n" "$build_output" + fi +} + +build_if_missing() { + if $DRY_RUN; then + print_command "$CONTAINER_ENGINE" image inspect "$SLOPTRAP_IMAGE_NAME" + return 0 + fi + if "$CONTAINER_ENGINE" image inspect "$SLOPTRAP_IMAGE_NAME" >/dev/null 2>&1; then + return 0 + fi + build_image +} + +stop_container() { + if $DRY_RUN; then + print_command "$CONTAINER_ENGINE" stop "$SLOPTRAP_CONTAINER_NAME" + return 0 + fi + "$CONTAINER_ENGINE" stop "$SLOPTRAP_CONTAINER_NAME" >/dev/null 2>&1 || true +} + +clean_environment() { + if ! $DRY_RUN; then + status_line "Cleaning %s\n" "$PROJECT_NAME" + fi + local helper_root="$CODE_DIR/.sloptrap-ignores" + if [[ -L $helper_root ]]; then + error "$helper_root: helper directory may not be a symlink" + fi + assert_path_within_code_dir "$helper_root" + stop_container + if $DRY_RUN; then + print_command "$CONTAINER_ENGINE" rm -f "$SLOPTRAP_CONTAINER_NAME" + print_command "$CONTAINER_ENGINE" rmi "$SLOPTRAP_IMAGE_NAME" + print_command rm -rf "$helper_root" + return 0 + fi + "$CONTAINER_ENGINE" rm -f "$SLOPTRAP_CONTAINER_NAME" >/dev/null 2>&1 || true + "$CONTAINER_ENGINE" rmi "$SLOPTRAP_IMAGE_NAME" >/dev/null 2>&1 || true + rm -rf "$helper_root" +} + +run_codex() { + if ! $DRY_RUN; then + status_line "Running %s\n" "$SLOPTRAP_IMAGE_NAME" + fi + local -a cmd=("${BASE_CONTAINER_CMD[@]}" "$SLOPTRAP_IMAGE_NAME") + if [[ ${#CODEX_ARGS_ARRAY[@]} -gt 0 ]]; then + cmd+=("${CODEX_ARGS_ARRAY[@]}") + fi + run_or_print "${cmd[@]}" +} + +run_login_target() { + if ! $DRY_RUN; then + status_line "Login %s\n" "$SLOPTRAP_IMAGE_NAME" + fi + local -a cmd=("${BASE_CONTAINER_CMD[@]}" "$SLOPTRAP_IMAGE_NAME" login) + run_or_print "${cmd[@]}" +} + +run_shell_target() { + if ! $DRY_RUN; then + status_line "Shell %s\n" "$SLOPTRAP_IMAGE_NAME" + fi + local -a cmd=("${BASE_CONTAINER_CMD[@]}" --entrypoint /bin/bash "$SLOPTRAP_IMAGE_NAME") + run_or_print "${cmd[@]}" +} + +dispatch_target() { + local target=$1 + case "$target" in + build) + build_image + ;; + rebuild) + rebuild_image + ;; + build-if-missing) + build_if_missing + ;; + run) + build_if_missing + run_codex + ;; + login) + build_if_missing + ensure_codex_home_dir + run_login_target + ;; + shell) + build_if_missing + run_shell_target + ;; + stop) + stop_container + ;; + clean) + clean_environment + ;; + *) + error "unknown target '$target'" + ;; + esac +} + +DRY_RUN=false +PRINT_CONFIG=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=true + shift + ;; + --print-config) + PRINT_CONFIG=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + usage >&2 + error "unknown flag '$1'" + ;; + *) + break + ;; + esac +done + +if [[ $# -lt 1 ]]; then + usage >&2 + exit 1 +fi + +CODE_DIR_INPUT=$1 +shift + +if [[ ! -d $CODE_DIR_INPUT ]]; then + error "code directory '$CODE_DIR_INPUT' does not exist" +fi + +CODE_DIR="$(cd "$CODE_DIR_INPUT" && pwd -P)" +MANIFEST_PATH="$CODE_DIR/$MANIFEST_BASENAME" + +if [[ -f $MANIFEST_PATH ]]; then + MANIFEST_PRESENT=true + parse_manifest "$MANIFEST_PATH" +fi + +PROJECT_NAME=${MANIFEST[name]-$(basename "$CODE_DIR")} +if [[ -z $PROJECT_NAME ]]; then + error "$MANIFEST_PATH: project name resolved to empty string" +fi +if [[ ! $PROJECT_NAME =~ $VALID_NAME_REGEX ]]; then + error "$MANIFEST_PATH: invalid project name '$PROJECT_NAME' (allowed: letters, digits, ., _, -)" +fi + +select_codex_home "$CODE_DIR" +ensure_ignore_helper_root +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 + NEED_LOGIN=true +fi + +TARGETS=("$@") +if [[ ${#TARGETS[@]} -eq 0 ]]; then + if [[ -n ${MANIFEST[default_targets]-} ]]; then + read -r -a TARGETS <<< "${MANIFEST[default_targets]}" + elif [[ -n ${MANIFEST[default_target]-} ]]; then + read -r -a TARGETS <<< "${MANIFEST[default_target]}" + else + TARGETS=("run") + fi +fi + +DEFAULT_TARGETS=("${TARGETS[@]}") + +PACKAGES_EXTRA=${MANIFEST[packages_extra]-} +if [[ -n ${MANIFEST[allow_host_network]-} ]]; then + case "${MANIFEST[allow_host_network],,}" in + 1|true|yes) + ALLOW_HOST_NETWORK=true + ;; + 0|false|no) + ALLOW_HOST_NETWORK=false + ;; + *) + error "$MANIFEST_PATH: allow_host_network must be true or false (got '${MANIFEST[allow_host_network]}')" + ;; + esac +fi + +forbidden_keys=(container_opts_extra security_opts_extra env_extra env_passthrough) +for forbidden_key in "${forbidden_keys[@]}"; do + if [[ -n ${MANIFEST[$forbidden_key]-} ]]; then + error "$MANIFEST_PATH: key '$forbidden_key' has been removed for security reasons" + fi +done + +if [[ -n $PACKAGES_EXTRA ]]; then + ensure_safe_for_make "packages_extra" "$PACKAGES_EXTRA" + validate_package_list "packages_extra" "$PACKAGES_EXTRA" +fi +CONTAINER_ENGINE="$(detect_container_engine)" +CODEX_ARGS_ARRAY=("${DEFAULT_CODEX_ARGS[@]}") +if [[ -n ${MANIFEST[codex_args]-} ]]; then + ensure_safe_for_make "codex_args" "${MANIFEST[codex_args]}" + declare -a manifest_codex_args=() + read -r -a manifest_codex_args <<< "${MANIFEST[codex_args]}" + CODEX_ARGS_ARRAY+=("${manifest_codex_args[@]}") + unset -v manifest_codex_args +fi +declare -a sanitized_codex_args=() +declare -a sandbox_pair=() +codex_args_index=0 +while [[ $codex_args_index -lt ${#CODEX_ARGS_ARRAY[@]} ]]; do + if [[ ${CODEX_ARGS_ARRAY[$codex_args_index]} == "--sandbox" ]]; then + if (( codex_args_index + 1 >= ${#CODEX_ARGS_ARRAY[@]} )); then + error "$MANIFEST_PATH: '--sandbox' flag requires a mode (workspace-write or workspace-read-only)" + fi + sandbox_pair=(--sandbox "${CODEX_ARGS_ARRAY[$((codex_args_index + 1))]}") + ((codex_args_index+=2)) + continue + fi + sanitized_codex_args+=("${CODEX_ARGS_ARRAY[$codex_args_index]}") + ((codex_args_index+=1)) +done +if [[ ${#sandbox_pair[@]} -gt 0 ]]; then + sanitized_codex_args+=("${sandbox_pair[@]}") +fi +CODEX_ARGS_ARRAY=("${sanitized_codex_args[@]}") +unset -v sanitized_codex_args sandbox_pair codex_args_index +ensure_safe_sandbox "${CODEX_ARGS_ARRAY[@]}" +if [[ ${#CODEX_ARGS_ARRAY[@]} -gt 0 ]]; then + CODEX_ARGS_DISPLAY=$(printf '%s ' "${CODEX_ARGS_ARRAY[@]}") + CODEX_ARGS_DISPLAY=${CODEX_ARGS_DISPLAY% } +else + CODEX_ARGS_DISPLAY="" +fi + +prepare_ignore_mounts "$CODE_DIR" +prepare_container_runtime + +if $PRINT_CONFIG; then + print_config "$MANIFEST_PATH" + exit 0 +fi + +AUTO_LOGIN=false +if [[ $NEED_LOGIN == true ]]; then + AUTO_LOGIN=true + for tgt in "${TARGETS[@]}"; do + if [[ $tgt == "login" ]]; then + AUTO_LOGIN=false + break + fi + done +fi + +if $AUTO_LOGIN; then + if ! $DRY_RUN; then + status_line "Codex login required (%s)\n" "$CODEX_HOME_HOST" + fi + ensure_codex_home_dir + dispatch_target login +fi + +for target in "${TARGETS[@]}"; do + dispatch_target "$target" +done diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..586eaf5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,12 @@ +# Test Scenarios + +This directory contains cases that stress Sloptrap's hardening and deployment flow. Each subdirectory mimics a user repository and focuses on a single class of behaviour. Use `run_tests.sh` to execute the automated checks with stubbed tooling. + +Current scenarios: + +- `mount_injection/` — exercises `.sloptrapignore` entries with `,` and `=` to ensure mount escape characters remain escaped and forces `build_if_missing` to execute the Codex download/build path. +- `root_target/` — ensures attempts to mask the project root are rejected. +- `symlink_escape/` — confirms symlink targets resolving outside the project are blocked. +- `manifest_injection/` — ensures disallowed `make.*` overrides abort parsing. +- `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. diff --git a/tests/helper_symlink/.sloptrap-ignores b/tests/helper_symlink/.sloptrap-ignores new file mode 120000 index 0000000..c25bddb --- /dev/null +++ b/tests/helper_symlink/.sloptrap-ignores @@ -0,0 +1 @@ +../.. \ No newline at end of file diff --git a/tests/helper_symlink/.sloptrapignore b/tests/helper_symlink/.sloptrapignore new file mode 100644 index 0000000..4bd922a --- /dev/null +++ b/tests/helper_symlink/.sloptrapignore @@ -0,0 +1 @@ +secrets/ diff --git a/tests/manifest_injection/.sloptrap b/tests/manifest_injection/.sloptrap new file mode 100644 index 0000000..7bdf644 --- /dev/null +++ b/tests/manifest_injection/.sloptrap @@ -0,0 +1 @@ +make.BAD-FLAG=1 diff --git a/tests/mount_injection/.gitignore b/tests/mount_injection/.gitignore new file mode 100644 index 0000000..0a26b8e --- /dev/null +++ b/tests/mount_injection/.gitignore @@ -0,0 +1 @@ +.sloptrap-ignores diff --git a/tests/mount_injection/.sloptrapignore b/tests/mount_injection/.sloptrapignore new file mode 100644 index 0000000..b982350 --- /dev/null +++ b/tests/mount_injection/.sloptrapignore @@ -0,0 +1 @@ +attack,source=/etc/passwd diff --git a/tests/mount_injection/attack,source=/etc/passwd b/tests/mount_injection/attack,source=/etc/passwd new file mode 100644 index 0000000..6254031 --- /dev/null +++ b/tests/mount_injection/attack,source=/etc/passwd @@ -0,0 +1 @@ +fake passwd content diff --git a/tests/root_target/.sloptrapignore b/tests/root_target/.sloptrapignore new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/tests/root_target/.sloptrapignore @@ -0,0 +1 @@ +. diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..f7bae3a --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$( + cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 + pwd -P +) +SLOPTRAP_BIN="$ROOT_DIR/sloptrap" +TEST_ROOT="$ROOT_DIR/tests" + +if [[ ! -x $SLOPTRAP_BIN ]]; then + printf 'error: sloptrap launcher not found at %s\n' "$SLOPTRAP_BIN" >&2 + exit 1 +fi + +failures=() + +run_shellcheck() { + printf '==> shellcheck\n' + if ! command -v shellcheck >/dev/null 2>&1; then + record_failure "shellcheck: shellcheck binary not found in PATH" + return + fi + if ! shellcheck "$SLOPTRAP_BIN"; then + record_failure "shellcheck: lint errors detected" + fi +} + +setup_stub_env() { + STUB_BIN=$(mktemp -d) + STUB_HOME=$(mktemp -d) + STUB_LOG=$(mktemp) + cat >"$STUB_BIN/podman" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ -z ${FAKE_PODMAN_LOG:-} ]]; then + printf 'FAKE_PODMAN_LOG is not set\n' >&2 + exit 1 +fi +verify_secret_mounts() { + local -a args=("$@") + if [[ -z ${SECRET_MASK_EXPECTED_TARGET:-} ]]; then + return 0 + fi + local saw=0 + local idx=0 + while (( idx < ${#args[@]} )); do + local arg=${args[$idx]} + if [[ $arg == "--mount" ]]; then + ((idx++)) + if (( idx >= ${#args[@]} )); then + printf 'podman stub: --mount missing spec\n' >&2 + return 1 + fi + local spec=${args[$idx]} + local target="" + local source="" + IFS=',' read -r -a parts <<< "$spec" + for part in "${parts[@]}"; do + case $part in + target=*) + target=${part#target=} + ;; + source=*) + source=${part#source=} + ;; + esac + done + if [[ -n ${SECRET_MASK_EXPECTED_TARGET:-} && $target == "$SECRET_MASK_EXPECTED_TARGET" ]]; then + saw=1 + if [[ -z $source || ! -f $source ]]; then + printf 'podman stub: masked source missing for %s\n' "$target" >&2 + return 1 + fi + if [[ -s $source ]]; then + printf 'podman stub: masked source leaked contents (%s)\n' "$source" >&2 + return 1 + fi + fi + fi + ((idx++)) + done + if [[ $saw -eq 0 ]]; then + printf 'podman stub: target %s not mounted\n' "${SECRET_MASK_EXPECTED_TARGET:-}" >&2 + return 1 + fi + return 0 +} + +if [[ ${1-} == "image" && ${2-} == "inspect" && ${FAKE_PODMAN_INSPECT_FAIL:-0} == 1 ]]; then + echo "FAKE PODMAN (fail): $*" >>"$FAKE_PODMAN_LOG" + exit 1 +fi + +if [[ ${SECRET_MASK_VERIFY:-0} == 1 && ${1-} == "run" ]]; then + if ! verify_secret_mounts "$@"; then + echo "FAKE PODMAN (secret-check failed): $*" >>"$FAKE_PODMAN_LOG" + exit 1 + fi +fi + +echo "FAKE PODMAN: $*" >>"$FAKE_PODMAN_LOG" +exit 0 +EOF + chmod +x "$STUB_BIN/podman" + cat >"$STUB_BIN/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ ${1-} == "-fsSL" ]]; then + cat <<'JSON' +{"assets":[{"name":"codex-x86_64-unknown-linux-gnu.tar.gz","digest":"sha256:feedface"}]} +JSON + exit 0 +fi +if [[ ${1-} == "-Lso" ]]; then + if [[ $# -lt 3 ]]; then + echo "curl stub expected output path" >&2 + exit 1 + fi + output=$2 + : >"$output" + exit 0 +fi +printf 'curl stub encountered unsupported args: %s\n' "$*" >&2 +exit 1 +EOF + chmod +x "$STUB_BIN/curl" + cat >"$STUB_BIN/jq" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +while [[ $# -gt 0 ]]; do + case "$1" in + -r) + shift + continue + ;; + *) + break + ;; + esac +done +cat >/dev/null +printf 'sha256:feedface\n' +EOF + chmod +x "$STUB_BIN/jq" + cat >"$STUB_BIN/sha256sum" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ ${1-} == "-c" ]]; then + shift + if [[ ${1-} == "-" ]]; then + shift + cat >/dev/null + exit 0 + fi +fi +printf 'sha256sum stub encountered unsupported args: %s\n' "$*" >&2 +exit 1 +EOF + chmod +x "$STUB_BIN/sha256sum" + cat >"$STUB_BIN/tar" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +dest="" +new_name="codex" +while [[ $# -gt 0 ]]; do + case "$1" in + -C) + shift + if [[ $# -eq 0 ]]; then + echo "tar stub missing directory for -C" >&2 + exit 1 + fi + dest=$1 + ;; + --transform=*) + transform=${1#--transform=} + transform=${transform#s/} + rest=${transform#*/} + new_name=${rest%%/*} + ;; + esac + shift +done +[[ -n $dest ]] || dest="." +mkdir -p "$dest" +cat >"$dest/$new_name" <<'BIN' +#!/usr/bin/env bash +echo "fake codex" +BIN +chmod +x "$dest/$new_name" +EOF + chmod +x "$STUB_BIN/tar" +} + +teardown_stub_env() { + rm -rf "${STUB_BIN:-}" "${STUB_HOME:-}" + rm -f "${STUB_LOG:-}" +} + +record_failure() { + failures+=("$1") +} + +run_mount_injection() { + local scenario_dir="$TEST_ROOT/mount_injection" + printf '==> mount_injection\n' + setup_stub_env + rm -rf "$scenario_dir/.sloptrap-ignores" + 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 "mount_injection: sloptrap exited non-zero" + teardown_stub_env + return + fi + + if ! grep -q -- "--mount type=bind" "$STUB_LOG"; then + record_failure "mount_injection: bind mount invocation missing" + fi + if grep -q -- "attack,source=/etc/passwd" "$STUB_LOG"; then + record_failure "mount_injection: unescaped mount key detected" + fi + if ! grep -q -- "attack\\\\,source\\\\=/etc/passwd" "$STUB_LOG"; then + record_failure "mount_injection: escaped mount key missing" + fi + if ! grep -q -- "FAKE PODMAN: build " "$STUB_LOG"; then + record_failure "mount_injection: image build path did not trigger" + fi + + teardown_stub_env +} + +run_root_target() { + local scenario_dir="$TEST_ROOT/root_target" + printf '==> root_target\n' + if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then + record_failure "root_target: expected rejection for project-root mask" + return + fi +} + +run_symlink_escape() { + local scenario_dir="$TEST_ROOT/symlink_escape" + printf '==> symlink_escape\n' + local secret_path="$ROOT_DIR/secrets.txt" + touch "$secret_path" + if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then + record_failure "symlink_escape: expected failure for symlink escape" + rm -f "$secret_path" + return + fi + rm -f "$secret_path" +} + +run_manifest_injection() { + local scenario_dir="$TEST_ROOT/manifest_injection" + printf '==> manifest_injection\n' + if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then + record_failure "manifest_injection: expected rejection of bad make override" + return + fi +} + +run_helper_symlink() { + local scenario_dir="$TEST_ROOT/helper_symlink" + printf '==> helper_symlink\n' + if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" >/dev/null 2>&1; then + record_failure "helper_symlink: expected rejection when helper directory is a symlink" + fi + if "$SLOPTRAP_BIN" "$scenario_dir" clean >/dev/null 2>&1; then + record_failure "helper_symlink: expected rejection for clean when helper directory is a symlink" + fi +} + +run_secret_mask() { + local scenario_dir="$TEST_ROOT/secret_mask" + printf '==> secret_mask\n' + setup_stub_env + local custom_workdir="/alt-workspace" + if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" \ + FAKE_PODMAN_INSPECT_FAIL=1 SECRET_MASK_VERIFY=1 \ + SECRET_MASK_EXPECTED_TARGET="${custom_workdir}/secret.txt" \ + SLOPTRAP_WORKDIR="$custom_workdir" \ + "$SLOPTRAP_BIN" "$scenario_dir" >/dev/null 2>&1; then + record_failure "secret_mask: masking check failed" + teardown_stub_env + return + fi + teardown_stub_env +} + +run_shellcheck +run_mount_injection +run_root_target +run_symlink_escape +run_manifest_injection +run_helper_symlink +run_secret_mask + +if [[ ${#failures[@]} -gt 0 ]]; then + printf '\nTest failures:\n' + for entry in "${failures[@]}"; do + printf ' - %s\n' "$entry" + done + exit 1 +fi + +printf '\nAll regression checks passed.\n' diff --git a/tests/secret_mask/.sloptrapignore b/tests/secret_mask/.sloptrapignore new file mode 100644 index 0000000..2d9d0f4 --- /dev/null +++ b/tests/secret_mask/.sloptrapignore @@ -0,0 +1 @@ +secret.txt diff --git a/tests/secret_mask/secret.txt b/tests/secret_mask/secret.txt new file mode 100644 index 0000000..4b37867 --- /dev/null +++ b/tests/secret_mask/secret.txt @@ -0,0 +1 @@ +super-secret-token diff --git a/tests/symlink_escape/.sloptrapignore b/tests/symlink_escape/.sloptrapignore new file mode 100644 index 0000000..fd1390f --- /dev/null +++ b/tests/symlink_escape/.sloptrapignore @@ -0,0 +1 @@ +cheat/secrets.txt diff --git a/tests/symlink_escape/cheat b/tests/symlink_escape/cheat new file mode 120000 index 0000000..c25bddb --- /dev/null +++ b/tests/symlink_escape/cheat @@ -0,0 +1 @@ +../.. \ No newline at end of file