From da001da48fe34795a30dfbef6692df8bcf6a272e Mon Sep 17 00:00:00 2001 From: Samuel Aubertin Date: Mon, 9 Mar 2026 18:46:36 +0100 Subject: [PATCH] Add capabilities at build --- .sloptrap | 6 +- README.md | 38 +- slop-apt | 43 ++ slopcap | 85 ++++ sloppodman | 97 ++++ sloptrap | 417 ++++++++++++++---- sloptrap-entrypoint | 30 ++ sloptrap-helperd | 146 ++++++ tests/capability_repo/.sloptrap | 3 + tests/invalid_manifest_capabilities/.sloptrap | 4 + tests/run_tests.sh | 114 ++++- tests/wizzard_build/.sloptrap | 1 - tests/wizzard_empty/.sloptrap | 1 - tests/wizzard_existing/.sloptrap | 1 - 14 files changed, 881 insertions(+), 105 deletions(-) create mode 100644 slop-apt create mode 100644 slopcap create mode 100644 sloppodman create mode 100644 sloptrap-entrypoint create mode 100644 sloptrap-helperd create mode 100644 tests/capability_repo/.sloptrap create mode 100644 tests/invalid_manifest_capabilities/.sloptrap diff --git a/.sloptrap b/.sloptrap index 54d552a..2ca19ec 100644 --- a/.sloptrap +++ b/.sloptrap @@ -1,4 +1,4 @@ -name=sloptrap -packages_extra=make shellcheck jq -codex_args=--sandbox workspace-write +name=skz-sloptrap +packages_extra=make shellcheck jq podman +capabilities= allow_host_network=false diff --git a/README.md b/README.md index 254efce..7020ef5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ brew install coreutils gnu-tar jq cat > path/to/project/.sloptrap <<'EOF' name=path/to/project packages_extra=make - codex_args=--sandbox danger-full-access --ask-for-approval never EOF cat > path/to/project/.sloptrapignore <<'EOF' @@ -54,7 +53,7 @@ brew install coreutils gnu-tar jq The manifest is optional. When absent, sloptrap derives: - `name = basename(project directory)` - `packages_extra = ""` (none) -- `codex_args = "--sandbox danger-full-access --ask-for-approval never"` +- `capabilities = ""` (none) If a build is requested and no `.sloptrap` exists, sloptrap prompts to create one interactively. Supported keys when the manifest is present: @@ -63,11 +62,13 @@ Supported keys when the manifest is present: | --- | --- | --- | | `name` | project directory name | Must match `^[A-Za-z0-9_.-]+$`. Used for image/container naming. | | `packages_extra` | *empty* | Additional Debian packages installed during `docker/podman build`. Tokens must be alphanumeric plus `+.-`. | -| `codex_args` | `--sandbox danger-full-access --ask-for-approval never` | Passed verbatim to the Codex CLI entrypoint. Tokens are shell-split, so quote values with spaces (e.g., `--profile security-audit`). | +| `capabilities` | *empty* | Optional privileged features. Supported values are `apt-install`, `packet-capture`, and `nested-podman`. | | `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`, `workspace-read-only`, or `danger-full-access`. Values containing `$`, `` ` ``, or newlines are rejected to prevent command injection. Setting illegal keys or malformed values aborts the run before containers start. +sloptrap always runs Codex with `--sandbox danger-full-access --ask-for-approval never`. `codex_args` is deprecated and rejected if present. + +Capability trust is local state, not part of the repository. Builds for manifests that request capabilities require either an interactive trust confirmation or `--trust-capabilities`. Trusted capabilities can then be activated per run with `--enable-capability `. ### `.sloptrapignore` @@ -79,13 +80,15 @@ Values containing `$`, `` ` ``, or newlines are rejected to prevent command inje ## CLI Reference ``` -./sloptrap [--dry-run] [--print-config] [target ...] +./sloptrap [--dry-run] [--print-config] [--trust-capabilities] [--enable-capability ...] [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. +- `--trust-capabilities` — trust the manifest's requested capabilities for the current build flow. +- `--enable-capability ` — enable a trusted runtime capability for this invocation. Repeat for multiple capabilities. - `-h, --help` — display usage. - `--` — stop option parsing; remaining arguments are treated as targets. @@ -94,9 +97,10 @@ 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 or empty, sloptrap prepends a login run before executing your targets. +- Fresh interactive `run` sessions receive a launcher-generated startup prompt telling the agent it is inside sloptrap, summarising the resolved manifest/runtime state, and pointing it at `/workspace/.sloptrap` for exact project configuration. `resume` does not inject that prompt again. - 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. +`--print-config` fields include `manifest_present=true|false`, requested/enabled capability lists, trust status, resolved paths, and the sanitised ignore mount roots so you can confirm what will be hidden inside the container. ### Regression Suite @@ -112,7 +116,7 @@ Targets are supplied after the code directory. When omitted, sloptrap defaults t | `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`. | +| `run` | Default goal. Runs the container with Codex using sloptrap's built-in runtime flags. | | `resume ` | Continues a Codex session by running `codex resume ` inside the container (builds if needed). | | `login` | Starts Codex in login mode to bootstrap shared `${HOME}/.codex/auth.json` credentials. | | `shell` | Launches `/bin/bash` inside the container for debugging. | @@ -122,22 +126,30 @@ Targets are supplied after the code directory. When omitted, sloptrap defaults t 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. +### Capability Helpers + +When a trusted capability is enabled for a run, the container includes helper commands: + +- `slop-apt install ` for session-scoped package installation. +- `slopcap capture --interface [--filter ] [--output ] [--stdout]` for packet capture. +- `sloppodman ...` for nested Podman workflows. `build` contexts and Dockerfiles must remain inside `/workspace`, and pushes are not supported. + ## Execution Environment -- Container engine: Podman or podman with identical command lines. Podman uses `--userns=keep-id`; Docker receives the equivalent `--user UID:GID`. +- Container engine: Podman or Docker with identical command lines. Podman uses `--userns=keep-id`; Docker receives the equivalent `--user UID:GID` for standard runs. - 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 project-scoped under `${HOME}/.codex/sloptrap/state/`, while credentials are shared via `${HOME}/.codex/auth.json`. +- Network: isolated networking is used by default; `allow_host_network=true` opts into `--network host`. +- Process context: standard runs drop capabilities, set `no-new-privileges`, use a read-only root filesystem, and keep scratch paths (`/tmp`, `/run`, `/run/lock`) on tmpfs. Capability-enabled runs may selectively add the runtime options required for the requested capability. +- Codex configuration: runtime flags are fixed to `--sandbox danger-full-access --ask-for-approval never`. 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`, 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. +- **Process isolation**: standard runs keep a read-only root filesystem and no extra Linux capabilities. Capability-enabled runs deliberately relax specific runtime controls for the enabled feature, so they should be treated as a stronger trust decision than a default session. +- **Networking stance**: traffic is unrestricted once it leaves the container. sloptrap does not enforce an allowlist or DNS policy. Host networking is opt-in per manifest; if you require an offline or firewalled workflow, sloptrap is not an appropriate launcher. - **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. diff --git a/slop-apt b/slop-apt new file mode 100644 index 0000000..f0efa03 --- /dev/null +++ b/slop-apt @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +helper_dir=${SLOPTRAP_HELPER_DIR:-/run/sloptrap-helper} +queue_dir="$helper_dir/queue" +mkdir -p "$queue_dir" + +if [[ ${1-} != "install" ]]; then + printf 'usage: slop-apt install \n' >&2 + exit 2 +fi +shift + +if [[ $# -eq 0 ]]; then + printf 'slop-apt: at least one package is required\n' >&2 + exit 2 +fi + +for package in "$@"; do + if [[ ! $package =~ ^[A-Za-z0-9+.-]+$ ]]; then + printf 'slop-apt: invalid package name %s\n' "$package" >&2 + exit 2 + fi +done + +request_dir=$(mktemp -d "$queue_dir/request.XXXXXX.req") +trap 'rm -rf "$request_dir"' EXIT INT TERM HUP +printf 'apt-install\n' >"$request_dir/op" +printf '%s\n' "$@" >"$request_dir/packages" + +while [[ ! -f "$request_dir/status" ]]; do + sleep 1 +done + +if [[ -s "$request_dir/stdout" ]]; then + cat "$request_dir/stdout" +fi +if [[ -s "$request_dir/stderr" ]]; then + cat "$request_dir/stderr" >&2 +fi + +status=$(<"$request_dir/status") +exit "$status" diff --git a/slopcap b/slopcap new file mode 100644 index 0000000..5776d88 --- /dev/null +++ b/slopcap @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +helper_dir=${SLOPTRAP_HELPER_DIR:-/run/sloptrap-helper} +queue_dir="$helper_dir/queue" +default_output=${SLOPTRAP_CAPTURE_DIR:-/codex/state/captures} +mkdir -p "$queue_dir" "$default_output" + +if [[ ${1-} != "capture" ]]; then + printf 'usage: slopcap capture --interface [--filter ] [--output ] [--stdout]\n' >&2 + exit 2 +fi +shift + +iface="" +filter="" +output="" +stdout_mode=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --interface) + shift + [[ $# -gt 0 ]] || { printf 'slopcap: --interface requires a value\n' >&2; exit 2; } + iface=$1 + ;; + --filter) + shift + [[ $# -gt 0 ]] || { printf 'slopcap: --filter requires a value\n' >&2; exit 2; } + filter=$1 + ;; + --output) + shift + [[ $# -gt 0 ]] || { printf 'slopcap: --output requires a value\n' >&2; exit 2; } + output=$1 + ;; + --stdout) + stdout_mode=1 + ;; + *) + printf 'slopcap: unsupported argument %s\n' "$1" >&2 + exit 2 + ;; + esac + shift +done + +[[ -n $iface ]] || { printf 'slopcap: --interface is required\n' >&2; exit 2; } +if [[ -z $output && $stdout_mode -eq 0 ]]; then + output="$default_output/capture-$(date +%s).pcap" +fi + +request_dir=$(mktemp -d "$queue_dir/request.XXXXXX.req") +trap 'rm -rf "$request_dir"' EXIT INT TERM HUP +printf 'packet-capture\n' >"$request_dir/op" +printf '%s\n' "$iface" >"$request_dir/interface" +printf '%s\n' "$filter" >"$request_dir/filter" +printf '%s\n' "$output" >"$request_dir/output" +printf '%s\n' "$stdout_mode" >"$request_dir/stdout_mode" + +stream_pid="" +if [[ $stdout_mode -eq 1 ]]; then + touch "$request_dir/stdout" + tail -f "$request_dir/stdout" & + stream_pid=$! +fi + +while [[ ! -f "$request_dir/status" ]]; do + sleep 1 +done + +if [[ -n $stream_pid ]]; then + kill "$stream_pid" >/dev/null 2>&1 || true + wait "$stream_pid" >/dev/null 2>&1 || true +fi + +if [[ $stdout_mode -eq 0 && -s "$request_dir/stdout" ]]; then + cat "$request_dir/stdout" +fi +if [[ -s "$request_dir/stderr" ]]; then + cat "$request_dir/stderr" >&2 +fi + +status=$(<"$request_dir/status") +exit "$status" diff --git a/sloppodman b/sloppodman new file mode 100644 index 0000000..1eb874a --- /dev/null +++ b/sloppodman @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -eq 0 ]]; then + printf 'usage: sloppodman ...\n' >&2 + exit 2 +fi + +subcommand=$1 +shift + +case "$subcommand" in + pull|build|tag|run|ps|logs|stop|rm|inspect) + ;; + *) + printf 'sloppodman: unsupported subcommand %s\n' "$subcommand" >&2 + exit 2 + ;; +esac + +workspace_root=${SLOPTRAP_WORKDIR:-/workspace} +podman_root=${SLOPTRAP_INNER_PODMAN_ROOT:-/codex/capabilities/podman/storage} +podman_runroot=${SLOPTRAP_INNER_PODMAN_RUNROOT:-/codex/capabilities/podman/run} +runtime_dir=${XDG_RUNTIME_DIR:-/codex/capabilities/podman/runtime} +mkdir -p "$podman_root" "$podman_runroot" "$runtime_dir" + +resolve_inner_path() { + local raw=$1 + if command -v realpath >/dev/null 2>&1; then + realpath -m "$raw" + return + fi + case "$raw" in + /*) printf '%s\n' "$raw" ;; + *) printf '%s/%s\n' "$(pwd -P)" "$raw" ;; + esac +} + +validate_workspace_path() { + local path=$1 + path=$(resolve_inner_path "$path") + case "$path" in + "$workspace_root"|"${workspace_root}/"*) ;; + *) + printf 'sloppodman: path must stay within %s (%s)\n' "$workspace_root" "$path" >&2 + exit 2 + ;; + esac +} + +if [[ $subcommand == "build" ]]; then + args=("$@") + context="" + idx=0 + while (( idx < ${#args[@]} )); do + arg=${args[$idx]} + case "$arg" in + -f|--file) + ((idx+=1)) + (( idx < ${#args[@]} )) || { printf 'sloppodman: %s requires a path\n' "$arg" >&2; exit 2; } + validate_workspace_path "${args[$idx]}" + ;; + --network) + ((idx+=1)) + (( idx < ${#args[@]} )) || { printf 'sloppodman: --network requires a value\n' >&2; exit 2; } + if [[ ${args[$idx]} == "host" && ${SLOPTRAP_INNER_PODMAN_HOST_NETWORK:-0} != 1 ]]; then + printf 'sloppodman: host networking is not available in this session\n' >&2 + exit 2 + fi + ;; + esac + ((idx+=1)) + done + if [[ ${#args[@]} -gt 0 ]]; then + context=${args[$(( ${#args[@]} - 1 ))]} + validate_workspace_path "$context" + fi +fi + +if [[ $subcommand == "run" ]]; then + args=("$@") + idx=0 + while (( idx < ${#args[@]} )); do + arg=${args[$idx]} + if [[ $arg == "--network" ]]; then + ((idx+=1)) + (( idx < ${#args[@]} )) || { printf 'sloppodman: --network requires a value\n' >&2; exit 2; } + if [[ ${args[$idx]} == "host" && ${SLOPTRAP_INNER_PODMAN_HOST_NETWORK:-0} != 1 ]]; then + printf 'sloppodman: host networking is not available in this session\n' >&2 + exit 2 + fi + fi + ((idx+=1)) + done +fi + +exec podman --root "$podman_root" --runroot "$podman_runroot" "$subcommand" "$@" diff --git a/sloptrap b/sloptrap index f372378..2acf70d 100755 --- a/sloptrap +++ b/sloptrap @@ -158,6 +158,7 @@ DEFAULT_CODEX_ARGS_DISPLAY=$(printf '%s ' "${DEFAULT_CODEX_ARGS[@]}") DEFAULT_CODEX_ARGS_DISPLAY=${DEFAULT_CODEX_ARGS_DISPLAY% } SLOPTRAP_IMAGE_LABEL_KEY="net.sk4nz.sloptrap.managed" SLOPTRAP_IMAGE_LABEL="${SLOPTRAP_IMAGE_LABEL_KEY}=1" +SLOPTRAP_SUPPORTED_CAPABILITIES=(apt-install packet-capture nested-podman) usage() { print_banner @@ -165,13 +166,15 @@ usage() { 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 " --enable-capability Enable a trusted runtime capability for this run\n" + comment_line " --trust-capabilities Trust the manifest's requested capabilities for this build\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 " codex_args=--sandbox danger-full-access --ask-for-approval never\n" + comment_line " capabilities=apt-install packet-capture\n" info_line "\n" info_line "Example targets:\n" comment_line " run Build if needed, then launch Codex\n" @@ -221,6 +224,9 @@ resolve_path_strict() { declare -A MANIFEST=() declare -a SLOPTRAP_IGNORE_ENTRIES=() declare -a IGNORE_MOUNT_ARGS=() +declare -a CODEX_ARGS_ARRAY=() +declare -a DEFAULT_TARGETS=() +declare -a ENABLED_CAPABILITIES_ARGS=() MANIFEST_PRESENT=false CURRENT_IGNORE_FILE="" @@ -231,6 +237,13 @@ CODEX_AUTH_FILE_HOST="" CODEX_STATE_KEY="" CODEX_HOME_BOOTSTRAP=false NEED_LOGIN=false +REQUESTED_CAPABILITIES="" +ENABLED_CAPABILITIES="" +CAPABILITY_MANIFEST_DIGEST="" +CAPABILITY_TRUST_ROOT_HOST="" +CAPABILITY_TRUST_FILE_HOST="" +CAPABILITY_BUILD_STAMP_HOST="" +CAPABILITY_STATE_HOST="" IGNORE_STUB_BASE="" IGNORE_HELPER_ROOT="" ALLOW_HOST_NETWORK=false @@ -299,10 +312,11 @@ FROM ${BASE_IMAGE} ENV DEBIAN_FRONTEND=noninteractive -ARG BASE_PACKAGES="curl bash ca-certificates libstdc++6 ripgrep xxd file procps" +ARG BASE_PACKAGES="curl bash ca-certificates libstdc++6 ripgrep xxd file procps util-linux" ARG EXTRA_PACKAGES="" +ARG CAPABILITY_PACKAGES="" RUN apt-get update \ - && apt-get install -y --no-install-recommends apt-utils ${BASE_PACKAGES} ${EXTRA_PACKAGES} \ + && apt-get install -y --no-install-recommends apt-utils ${BASE_PACKAGES} ${EXTRA_PACKAGES} ${CAPABILITY_PACKAGES} \ && rm -rf /var/lib/apt/lists/* ARG CODEX_UID=1337 @@ -314,14 +328,20 @@ RUN groupadd --gid ${CODEX_GID} sloptrap \ ARG CODEX_BIN=codex ARG CODEX_CONF=config/config.toml COPY ${CODEX_BIN} /usr/local/bin/codex +COPY sloptrap-entrypoint /usr/local/bin/sloptrap-entrypoint +COPY sloptrap-helperd /usr/local/bin/sloptrap-helperd +COPY slop-apt /usr/local/bin/slop-apt +COPY slopcap /usr/local/bin/slopcap +COPY sloppodman /usr/local/bin/sloppodman -RUN chown -R sloptrap:sloptrap /home/sloptrap -USER sloptrap +RUN chmod 0755 /usr/local/bin/sloptrap-entrypoint /usr/local/bin/sloptrap-helperd \ + /usr/local/bin/slop-apt /usr/local/bin/slopcap /usr/local/bin/sloppodman \ + && chown -R sloptrap:sloptrap /home/sloptrap WORKDIR /workspace ENV SHELL=/bin/bash HOME=/home/sloptrap -ENTRYPOINT ["codex"] +ENTRYPOINT ["/usr/local/bin/sloptrap-entrypoint"] EOF } @@ -364,6 +384,13 @@ prepare_build_context() { populate_dockerfile "$SLOPTRAP_DOCKERFILE_PATH" validate_basename "$SLOPTRAP_CODEX_BIN_NAME" CODEX_BIN_PATH="$SLOPTRAP_BUILD_CONTEXT/$SLOPTRAP_CODEX_BIN_NAME" + local helper + for helper in sloptrap-entrypoint sloptrap-helperd slop-apt slopcap sloppodman; do + if [[ ! -f "$SCRIPT_DIR/$helper" ]]; then + error "required helper '$SCRIPT_DIR/$helper' not found" + fi + cp "$SCRIPT_DIR/$helper" "$SLOPTRAP_BUILD_CONTEXT/$helper" + done } select_codex_home() { @@ -394,6 +421,101 @@ select_codex_home() { fi } +compute_manifest_digest() { + if [[ -f $MANIFEST_PATH ]]; then + local digest + digest=$(sha256sum "$MANIFEST_PATH") + printf '%s' "${digest%% *}" + return 0 + fi + printf 'no-manifest' +} + +select_capability_state_paths() { + local capability_root="$CODEX_ROOT_HOST/sloptrap/capabilities" + CAPABILITY_TRUST_ROOT_HOST="$capability_root/trust" + CAPABILITY_TRUST_FILE_HOST="$CAPABILITY_TRUST_ROOT_HOST/$CODEX_STATE_KEY.trust" + CAPABILITY_BUILD_STAMP_HOST="$capability_root/builds/$CODEX_STATE_KEY.stamp" + CAPABILITY_STATE_HOST="$CODEX_STATE_HOME_HOST/capabilities" +} + +ensure_capability_state_paths() { + local capability_root="$CODEX_ROOT_HOST/sloptrap/capabilities" + ensure_codex_directory "$capability_root" "sloptrap capability namespace" + ensure_codex_directory "$CAPABILITY_TRUST_ROOT_HOST" "sloptrap capability trust root" + ensure_codex_directory "$(dirname "$CAPABILITY_BUILD_STAMP_HOST")" "sloptrap capability build stamp root" + ensure_codex_directory "$CAPABILITY_STATE_HOST" "project capability state" +} + +capability_trust_matches_current() { + [[ -f $CAPABILITY_TRUST_FILE_HOST ]] || return 1 + local trusted_digest trusted_caps + trusted_digest=$(sed -n '1p' "$CAPABILITY_TRUST_FILE_HOST" 2>/dev/null || true) + trusted_caps=$(sed -n '2p' "$CAPABILITY_TRUST_FILE_HOST" 2>/dev/null || true) + [[ $trusted_digest == "$CAPABILITY_MANIFEST_DIGEST" && $trusted_caps == "$REQUESTED_CAPABILITIES" ]] +} + +record_capability_trust() { + ensure_capability_state_paths + if $DRY_RUN; then + print_command mkdir -p "$(dirname "$CAPABILITY_TRUST_FILE_HOST")" + print_command sh -c "printf '%s\\n%s\\n' '$CAPABILITY_MANIFEST_DIGEST' '$REQUESTED_CAPABILITIES' > '$CAPABILITY_TRUST_FILE_HOST'" + return 0 + fi + printf '%s\n%s\n' "$CAPABILITY_MANIFEST_DIGEST" "$REQUESTED_CAPABILITIES" >"$CAPABILITY_TRUST_FILE_HOST" +} + +prompt_capability_trust() { + local tty_path="/dev/tty" + info_line "Manifest requests privileged capabilities: %s\n" "$REQUESTED_CAPABILITIES" + printf '%s' "$PREFIX_TEXT" >"$tty_path" + printf '%b' "$COLOR_TEXT" >"$tty_path" + printf 'Trust these capabilities for this project build? [y/N]: ' >"$tty_path" + printf '%b' "$RESET" >"$tty_path" + local input + if ! IFS= read -r input <"$tty_path"; then + error "capability trust requires an interactive terminal or --trust-capabilities" + fi + case "${input,,}" in + y|yes) + record_capability_trust + ;; + *) + error "capability trust not granted" + ;; + esac +} + +ensure_capability_trust() { + [[ -n $REQUESTED_CAPABILITIES ]] || return 0 + capability_trust_matches_current && return 0 + if $TRUST_CAPABILITIES; then + record_capability_trust + return 0 + fi + if [[ ! -t 0 ]]; then + error "requested capabilities require prior trust or --trust-capabilities" + fi + prompt_capability_trust +} + +write_capability_build_stamp() { + ensure_capability_state_paths + if $DRY_RUN; then + print_command sh -c "printf '%s\\n%s\\n' '$CAPABILITY_MANIFEST_DIGEST' '$REQUESTED_CAPABILITIES' > '$CAPABILITY_BUILD_STAMP_HOST'" + return 0 + fi + printf '%s\n%s\n' "$CAPABILITY_MANIFEST_DIGEST" "$REQUESTED_CAPABILITIES" >"$CAPABILITY_BUILD_STAMP_HOST" +} + +capability_build_stamp_matches_current() { + [[ -f $CAPABILITY_BUILD_STAMP_HOST ]] || return 1 + local stamp_digest stamp_caps + stamp_digest=$(sed -n '1p' "$CAPABILITY_BUILD_STAMP_HOST" 2>/dev/null || true) + stamp_caps=$(sed -n '2p' "$CAPABILITY_BUILD_STAMP_HOST" 2>/dev/null || true) + [[ $stamp_digest == "$CAPABILITY_MANIFEST_DIGEST" && $stamp_caps == "$REQUESTED_CAPABILITIES" ]] +} + assert_path_within_code_dir() { local candidate=$1 local resolved @@ -638,6 +760,58 @@ validate_package_list() { done } +validate_capability_list() { + local key=$1 + local raw=$2 + local source=${3:-$MANIFEST_PATH} + [[ -z $raw ]] && return 0 + local token supported capability + for token in $raw; do + supported=false + for capability in "${SLOPTRAP_SUPPORTED_CAPABILITIES[@]}"; do + if [[ $token == "$capability" ]]; then + supported=true + break + fi + done + if [[ $supported != true ]]; then + error "$source: invalid capability '$token' in '$key'" + fi + done +} + +normalize_capability_list() { + local raw=$1 + [[ -z $raw ]] && return 0 + local token + for token in $raw; do + printf '%s\n' "$token" + done | sort -u | tr '\n' ' ' | sed 's/ $//' +} + +capability_list_contains() { + local list=$1 + local needle=$2 + local token + for token in $list; do + if [[ $token == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +validate_enabled_capabilities() { + local enabled=$1 + local requested=$2 + local capability + for capability in $enabled; do + if ! capability_list_contains "$requested" "$capability"; then + error "runtime capability '$capability' is not requested by $MANIFEST_PATH" + fi + done +} + detect_container_engine() { local override=${SLOPTRAP_CONTAINER_ENGINE-} if [[ -n $override ]]; then @@ -726,18 +900,6 @@ normalize_wizzard_allow_host_network() { esac } -validate_wizzard_codex_args() { - local value=$1 - ensure_safe_for_make "codex_args" "$value" - local -a args=("${DEFAULT_CODEX_ARGS[@]}") - local -a tokens=() - if [[ -n $value ]]; then - read -r -a tokens <<< "$value" - args+=("${tokens[@]}") - fi - ensure_safe_sandbox "${args[@]}" -} - run_wizzard() { local manifest_path=$1 if [[ -L $manifest_path ]]; then @@ -752,12 +914,12 @@ run_wizzard() { local default_name local default_packages_extra - local default_codex_args + local default_capabilities local default_allow_host_network default_name=$(manifest_default_value "name" "$(basename "$CODE_DIR")") default_packages_extra=$(manifest_default_value "packages_extra" "") - default_codex_args=$(manifest_default_value "codex_args" "$DEFAULT_CODEX_ARGS_DISPLAY") + default_capabilities=$(manifest_default_value "capabilities" "") default_allow_host_network=$(manifest_default_value "allow_host_network" "false") local action="Creating" @@ -789,16 +951,6 @@ run_wizzard() { break done - while true; do - info_line "codex_args: Extra CLI flags passed to Codex at runtime.\n" - value=$(prompt_manifest_value "codex_args" "$default_codex_args") - value=$(trim "$value") - [[ -n $value ]] || value=$default_codex_args - validate_wizzard_codex_args "$value" - default_codex_args=$value - break - done - while true; do info_line "allow_host_network: Use host networking instead of an isolated bridge.\n" value=$(prompt_manifest_value "allow_host_network" "$default_allow_host_network") @@ -812,7 +964,7 @@ run_wizzard() { cat > "$manifest_path" <= ${#args[@]} )); then - error "$MANIFEST_PATH: '--sandbox' flag requires a mode (workspace-write, workspace-read-only, or danger-full-access)" + error "runtime '--sandbox' flag requires a mode (workspace-write, workspace-read-only, or danger-full-access)" 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, workspace-read-only, or danger-full-access)" + error "runtime flags must include '--sandbox ' (workspace-write, workspace-read-only, or danger-full-access)" fi case "$sandbox_mode" in workspace-write|workspace-read-only|danger-full-access) ;; *) - error "$MANIFEST_PATH: sandbox mode '$sandbox_mode' is not allowed (expected workspace-write, workspace-read-only, or danger-full-access)" + error "sandbox mode '$sandbox_mode' is not allowed (expected workspace-write, workspace-read-only, or danger-full-access)" ;; esac } @@ -1108,6 +1310,8 @@ targets_need_build() { prepare_container_runtime() { resolve_container_workdir SLOPTRAP_PACKAGES_EXTRA_RESOLVED=$PACKAGES_EXTRA + SLOPTRAP_PACKAGES_CAPABILITY="" + SLOPTRAP_RUN_AS_ROOT=false SLOPTRAP_SHARED_DIR_ABS="$CODE_DIR" if [[ ! -d $SLOPTRAP_SHARED_DIR_ABS ]]; then error "shared directory '$SLOPTRAP_SHARED_DIR_ABS' does not exist" @@ -1129,9 +1333,19 @@ prepare_container_runtime() { else SLOPTRAP_DOCKERFILE_SOURCE="" fi + if [[ -n $REQUESTED_CAPABILITIES && -n $SLOPTRAP_DOCKERFILE_SOURCE ]]; then + error "capabilities require the embedded Dockerfile; custom Dockerfile overrides are not supported" + fi - SLOPTRAP_PACKAGES_BASE=$(get_env_default "SLOPTRAP_PACKAGES" "curl bash ca-certificates libstdc++6 git ripgrep xxd file procps") + SLOPTRAP_PACKAGES_BASE=$(get_env_default "SLOPTRAP_PACKAGES" "curl bash ca-certificates libstdc++6 git ripgrep xxd file procps util-linux") validate_package_list "SLOPTRAP_PACKAGES" "$SLOPTRAP_PACKAGES_BASE" "SLOPTRAP_PACKAGES" + if capability_list_contains "$REQUESTED_CAPABILITIES" "packet-capture"; then + SLOPTRAP_PACKAGES_CAPABILITY+=" tcpdump" + fi + if capability_list_contains "$REQUESTED_CAPABILITIES" "nested-podman"; then + SLOPTRAP_PACKAGES_CAPABILITY+=" podman fuse-overlayfs slirp4netns" + fi + SLOPTRAP_PACKAGES_CAPABILITY=$(normalize_package_list "$SLOPTRAP_PACKAGES_CAPABILITY") local default_codex_archive default_codex_archive=$(detect_codex_archive_name) local env_codex_archive @@ -1178,7 +1392,8 @@ prepare_container_runtime() { 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_ROOTFS_READONLY_DEFAULT=$(get_env_default "SLOPTRAP_ROOTFS_READONLY" "1") + SLOPTRAP_ROOTFS_READONLY=$SLOPTRAP_ROOTFS_READONLY_DEFAULT SLOPTRAP_IMAGE_NAME=$(get_env_default "SLOPTRAP_IMAGE_NAME" "${PROJECT_NAME}-sloptrap-image") SLOPTRAP_CONTAINER_NAME=$(get_env_default "SLOPTRAP_CONTAINER_NAME" "${PROJECT_NAME}-sloptrap-container") SLOPTRAP_IMAGE_NAME=$(sanitize_engine_name "$SLOPTRAP_IMAGE_NAME") @@ -1186,6 +1401,7 @@ prepare_container_runtime() { local -a network_opts=(--network "$SLOPTRAP_NETWORK_NAME" --init) local -a security_opts=(--cap-drop=ALL --security-opt no-new-privileges) + local -a capability_opts=() if [[ -n $SLOPTRAP_SECURITY_OPTS_EXTRA ]]; then local -a extra_opts=() read -r -a extra_opts <<< "$SLOPTRAP_SECURITY_OPTS_EXTRA" @@ -1208,6 +1424,18 @@ prepare_container_runtime() { done fi + if capability_list_contains "$ENABLED_CAPABILITIES" "apt-install"; then + SLOPTRAP_ROOTFS_READONLY=0 + SLOPTRAP_RUN_AS_ROOT=true + fi + if capability_list_contains "$ENABLED_CAPABILITIES" "packet-capture"; then + capability_opts+=(--cap-add NET_RAW --cap-add NET_ADMIN) + SLOPTRAP_RUN_AS_ROOT=true + fi + if capability_list_contains "$ENABLED_CAPABILITIES" "nested-podman"; then + capability_opts+=(--device /dev/fuse) + fi + local rootfs_flag=() case "${SLOPTRAP_ROOTFS_READONLY,,}" in 1|true|yes) @@ -1226,6 +1454,13 @@ prepare_container_runtime() { -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" ) + if capability_list_contains "$ENABLED_CAPABILITIES" "nested-podman"; then + volume_opts+=( + -v "$CAPABILITY_STATE_HOST/podman-storage:$SLOPTRAP_CODEX_HOME_CONT/capabilities/podman/storage$SLOPTRAP_VOLUME_LABEL" + -v "$CAPABILITY_STATE_HOST/podman-run:$SLOPTRAP_CODEX_HOME_CONT/capabilities/podman/run$SLOPTRAP_VOLUME_LABEL" + -v "$CAPABILITY_STATE_HOST/podman-runtime:$SLOPTRAP_CODEX_HOME_CONT/capabilities/podman/runtime$SLOPTRAP_VOLUME_LABEL" + ) + fi local -a env_args=( -e "HOME=$SLOPTRAP_CODEX_HOME_CONT" @@ -1233,7 +1468,18 @@ prepare_container_runtime() { -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" + -e "SLOPTRAP_WORKDIR=$SLOPTRAP_WORKDIR" + -e "SLOPTRAP_HELPER_DIR=/run/sloptrap-helper" + -e "SLOPTRAP_ACTIVE_CAPABILITIES=$ENABLED_CAPABILITIES" + -e "SLOPTRAP_CAPTURE_DIR=$SLOPTRAP_CODEX_HOME_CONT/state/captures" + -e "SLOPTRAP_AUDIT_LOG=$SLOPTRAP_CODEX_HOME_CONT/state/capabilities.log" + -e "SLOPTRAP_INNER_PODMAN_ROOT=$SLOPTRAP_CODEX_HOME_CONT/capabilities/podman/storage" + -e "SLOPTRAP_INNER_PODMAN_RUNROOT=$SLOPTRAP_CODEX_HOME_CONT/capabilities/podman/run" + -e "XDG_RUNTIME_DIR=$SLOPTRAP_CODEX_HOME_CONT/capabilities/podman/runtime" ) + if capability_list_contains "$ENABLED_CAPABILITIES" "nested-podman" && [[ $SLOPTRAP_NETWORK_NAME == "host" ]]; then + env_args+=(-e "SLOPTRAP_INNER_PODMAN_HOST_NETWORK=1") + fi local uid gid uid=$(id -u) @@ -1242,10 +1488,14 @@ prepare_container_runtime() { if [[ $CONTAINER_ENGINE == "podman" ]]; then user_opts=(--userns="keep-id:uid=$uid,gid=$gid" "${user_opts[@]}") fi + if $SLOPTRAP_RUN_AS_ROOT; then + user_opts=() + fi CONTAINER_SHARED_OPTS=( "${network_opts[@]}" "${security_opts[@]}" + "${capability_opts[@]}" "${resource_opts[@]}" "${rootfs_flag[@]}" "${tmpfs_opts[@]}" @@ -1264,13 +1514,16 @@ prepare_container_runtime() { } build_image() { + ensure_capability_trust ensure_codex_binary if [[ $SKIP_BUILD_BANNER != true ]]; then print_banner fi print_manifest_summary local extra_packages_arg + local capability_packages_arg extra_packages_arg=$(normalize_package_list "$SLOPTRAP_PACKAGES_EXTRA_RESOLVED") + capability_packages_arg=$(normalize_package_list "$SLOPTRAP_PACKAGES_CAPABILITY") if ! $DRY_RUN; then status_line "Building %s\n" "$SLOPTRAP_IMAGE_NAME" fi @@ -1280,6 +1533,7 @@ build_image() { -f "$SLOPTRAP_DOCKERFILE_PATH" --label "$SLOPTRAP_IMAGE_LABEL" --build-arg "BASE_PACKAGES=$SLOPTRAP_PACKAGES_BASE" + --build-arg "CAPABILITY_PACKAGES=$capability_packages_arg" --build-arg "CODEX_BIN=$SLOPTRAP_CODEX_BIN_NAME" --build-arg "CODEX_UID=$SLOPTRAP_CODEX_UID" --build-arg "CODEX_GID=$SLOPTRAP_CODEX_GID" @@ -1301,9 +1555,11 @@ build_image() { if [[ -n $build_output ]]; then comment_line "Image %s\n" "$build_output" fi + write_capability_build_stamp } rebuild_image() { + ensure_capability_trust ensure_codex_binary if [[ $SKIP_BUILD_BANNER != true ]]; then print_banner @@ -1313,13 +1569,16 @@ rebuild_image() { status_line "Rebuilding %s (no cache)\n" "$SLOPTRAP_IMAGE_NAME" fi local extra_packages_arg + local capability_packages_arg extra_packages_arg=$(normalize_package_list "$SLOPTRAP_PACKAGES_EXTRA_RESOLVED") + capability_packages_arg=$(normalize_package_list "$SLOPTRAP_PACKAGES_CAPABILITY") local -a cmd=( "$CONTAINER_ENGINE" build --no-cache --quiet -t "$SLOPTRAP_IMAGE_NAME" -f "$SLOPTRAP_DOCKERFILE_PATH" --label "$SLOPTRAP_IMAGE_LABEL" --build-arg "BASE_PACKAGES=$SLOPTRAP_PACKAGES_BASE" + --build-arg "CAPABILITY_PACKAGES=$capability_packages_arg" --build-arg "CODEX_BIN=$SLOPTRAP_CODEX_BIN_NAME" --build-arg "CODEX_UID=$SLOPTRAP_CODEX_UID" --build-arg "CODEX_GID=$SLOPTRAP_CODEX_GID" @@ -1341,14 +1600,21 @@ rebuild_image() { if [[ -n $build_output ]]; then comment_line "Image %s\n" "$build_output" fi + write_capability_build_stamp } build_if_missing() { + ensure_capability_trust 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 + if ! capability_build_stamp_matches_current; then + warn "image '$SLOPTRAP_IMAGE_NAME' capability stamp mismatch; rebuilding" + rebuild_image + return 0 + fi local created if created=$("$CONTAINER_ENGINE" image inspect --format '{{.Created}}' "$SLOPTRAP_IMAGE_NAME" 2>/dev/null); then local created_epoch @@ -1411,7 +1677,7 @@ prune_sloptrap_images() { run_codex_command() { local -a extra_args=("$@") ensure_codex_storage_paths - local -a cmd=("${BASE_CONTAINER_CMD[@]}" "$SLOPTRAP_IMAGE_NAME") + local -a cmd=("${BASE_CONTAINER_CMD[@]}" "$SLOPTRAP_IMAGE_NAME" "codex") if [[ ${#CODEX_ARGS_ARRAY[@]} -gt 0 ]]; then cmd+=("${CODEX_ARGS_ARRAY[@]}") fi @@ -1425,7 +1691,9 @@ run_codex() { if ! $DRY_RUN; then status_line "Running %s\n" "$SLOPTRAP_IMAGE_NAME" fi - run_codex_command + local runtime_prompt + runtime_prompt=$(build_runtime_context_prompt) + run_codex_command "$runtime_prompt" } run_login_target() { @@ -1433,7 +1701,7 @@ 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) + local -a cmd=("${BASE_CONTAINER_CMD[@]}" "$SLOPTRAP_IMAGE_NAME" "codex" login) run_or_print "${cmd[@]}" } @@ -1442,7 +1710,7 @@ 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") + local -a cmd=("${BASE_CONTAINER_CMD[@]}" "$SLOPTRAP_IMAGE_NAME" /bin/bash) run_or_print "${cmd[@]}" } @@ -1509,6 +1777,7 @@ dispatch_target() { DRY_RUN=false PRINT_CONFIG=false SKIP_BUILD_BANNER=false +TRUST_CAPABILITIES=false while [[ $# -gt 0 ]]; do case "$1" in @@ -1520,6 +1789,16 @@ while [[ $# -gt 0 ]]; do PRINT_CONFIG=true shift ;; + --enable-capability) + shift + [[ $# -gt 0 ]] || error "--enable-capability requires a capability name" + ENABLED_CAPABILITIES_ARGS+=("$1") + shift + ;; + --trust-capabilities) + TRUST_CAPABILITIES=true + shift + ;; -h|--help) usage exit 0 @@ -1599,6 +1878,8 @@ if [[ ! $PROJECT_NAME =~ $VALID_NAME_REGEX ]]; then fi select_codex_home "$CODE_DIR" +select_capability_state_paths +CAPABILITY_MANIFEST_DIGEST=$(compute_manifest_digest) ensure_ignore_helper_root IGNORE_STUB_BASE="$IGNORE_HELPER_ROOT/session-${BASHPID:-$$}" resolve_sloptrap_ignore "$CODE_DIR" @@ -1608,7 +1889,7 @@ if [[ ! -s "$CODEX_AUTH_FILE_HOST" ]]; then NEED_LOGIN=true fi -TARGETS=("$@") +TARGETS=("${TARGETS_INPUT[@]}") if [[ ${#TARGETS[@]} -eq 0 ]]; then TARGETS=("run") fi @@ -1616,6 +1897,14 @@ fi DEFAULT_TARGETS=("${TARGETS[@]}") PACKAGES_EXTRA=${MANIFEST[packages_extra]-} +REQUESTED_CAPABILITIES=$(normalize_capability_list "${MANIFEST[capabilities]-}") +if [[ -n $REQUESTED_CAPABILITIES ]]; then + ensure_safe_for_make "capabilities" "$REQUESTED_CAPABILITIES" +fi +validate_capability_list "capabilities" "$REQUESTED_CAPABILITIES" +ENABLED_CAPABILITIES=$(normalize_capability_list "${ENABLED_CAPABILITIES_ARGS[*]-}") +validate_capability_list "--enable-capability" "$ENABLED_CAPABILITIES" "command line" +validate_enabled_capabilities "$ENABLED_CAPABILITIES" "$REQUESTED_CAPABILITIES" if [[ -n ${MANIFEST[allow_host_network]-} ]]; then case "${MANIFEST[allow_host_network],,}" in 1|true|yes) @@ -1630,9 +1919,12 @@ if [[ -n ${MANIFEST[allow_host_network]-} ]]; then esac fi -forbidden_keys=(container_opts_extra security_opts_extra env_extra env_passthrough default_targets default_target) +forbidden_keys=(codex_args container_opts_extra security_opts_extra env_extra env_passthrough default_targets default_target) for forbidden_key in "${forbidden_keys[@]}"; do if [[ -n ${MANIFEST[$forbidden_key]-} ]]; then + if [[ $forbidden_key == "codex_args" ]]; then + error "$MANIFEST_PATH: key 'codex_args' has been deprecated; sloptrap now always uses '$DEFAULT_CODEX_ARGS_DISPLAY'" + fi error "$MANIFEST_PATH: key '$forbidden_key' has been removed for security reasons" fi done @@ -1643,43 +1935,8 @@ if [[ -n $PACKAGES_EXTRA ]]; then 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]}" - manifest_codex_args_value=$(trim "${MANIFEST[codex_args]}") - if [[ $manifest_codex_args_value != "$DEFAULT_CODEX_ARGS_DISPLAY" ]]; then - declare -a manifest_codex_args=() - read -r -a manifest_codex_args <<< "$manifest_codex_args_value" - CODEX_ARGS_ARRAY+=("${manifest_codex_args[@]}") - unset -v manifest_codex_args - fi -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, workspace-read-only, or danger-full-access)" - 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 +CODEX_ARGS_DISPLAY=$DEFAULT_CODEX_ARGS_DISPLAY prepare_ignore_mounts "$CODE_DIR" prepare_container_runtime diff --git a/sloptrap-entrypoint b/sloptrap-entrypoint new file mode 100644 index 0000000..d0f58ac --- /dev/null +++ b/sloptrap-entrypoint @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +helper_pid="" + +cleanup() { + if [[ -n $helper_pid ]]; then + kill "$helper_pid" >/dev/null 2>&1 || true + wait "$helper_pid" >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT INT TERM HUP + +if [[ $# -eq 0 ]]; then + set -- codex +fi + +if [[ $(id -u) -eq 0 ]]; then + helper_dir=${SLOPTRAP_HELPER_DIR:-/run/sloptrap-helper} + mkdir -p "$helper_dir/queue" + chmod 700 "$helper_dir" + if [[ -n ${SLOPTRAP_ACTIVE_CAPABILITIES:-} ]]; then + /usr/local/bin/sloptrap-helperd & + helper_pid=$! + fi + exec runuser -u sloptrap --preserve-environment -- "$@" +fi + +exec "$@" diff --git a/sloptrap-helperd b/sloptrap-helperd new file mode 100644 index 0000000..3860574 --- /dev/null +++ b/sloptrap-helperd @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +helper_dir=${SLOPTRAP_HELPER_DIR:-/run/sloptrap-helper} +queue_dir="$helper_dir/queue" +caps=${SLOPTRAP_ACTIVE_CAPABILITIES:-} +audit_log=${SLOPTRAP_AUDIT_LOG:-/codex/state/capabilities.log} +mkdir -p "$queue_dir" "$(dirname "$audit_log")" + +has_capability() { + local needle=$1 + local token + for token in $caps; do + if [[ $token == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +log_action() { + local op=$1 + local details=$2 + local status=$3 + printf '%s op=%s status=%s %s\n' "$(date -u +%FT%TZ)" "$op" "$status" "$details" >>"$audit_log" +} + +write_status() { + local request_dir=$1 + local status=$2 + printf '%s\n' "$status" >"$request_dir/status" +} + +run_apt_install() { + local request_dir=$1 + has_capability "apt-install" || { + printf 'capability apt-install is not active\n' >"$request_dir/stderr" + write_status "$request_dir" 126 + log_action "apt-install" "packages=denied" 126 + return + } + local packages_file="$request_dir/packages" + if [[ ! -f $packages_file ]]; then + printf 'missing package list\n' >"$request_dir/stderr" + write_status "$request_dir" 2 + log_action "apt-install" "packages=missing" 2 + return + fi + mapfile -t packages <"$packages_file" + if [[ ${#packages[@]} -eq 0 ]]; then + printf 'package list is empty\n' >"$request_dir/stderr" + write_status "$request_dir" 2 + log_action "apt-install" "packages=empty" 2 + return + fi + if apt-get update >"$request_dir/stdout" 2>"$request_dir/stderr" \ + && apt-get install -y --no-install-recommends "${packages[@]}" >>"$request_dir/stdout" 2>>"$request_dir/stderr"; then + write_status "$request_dir" 0 + log_action "apt-install" "packages=${packages[*]}" 0 + return + fi + write_status "$request_dir" 1 + log_action "apt-install" "packages=${packages[*]}" 1 +} + +run_packet_capture() { + local request_dir=$1 + has_capability "packet-capture" || { + printf 'capability packet-capture is not active\n' >"$request_dir/stderr" + write_status "$request_dir" 126 + log_action "packet-capture" "interface=denied" 126 + return + } + local iface_file="$request_dir/interface" + [[ -f $iface_file ]] || { + printf 'missing interface\n' >"$request_dir/stderr" + write_status "$request_dir" 2 + log_action "packet-capture" "interface=missing" 2 + return + } + local iface filter_file output_file stdout_mode + iface=$(<"$iface_file") + filter_file="$request_dir/filter" + output_file="$request_dir/output" + stdout_mode=0 + [[ -f "$request_dir/stdout_mode" ]] && stdout_mode=$(<"$request_dir/stdout_mode") + local -a cmd=(tcpdump -i "$iface") + if [[ -s $filter_file ]]; then + local filter + filter=$(<"$filter_file") + local -a filter_tokens=() + read -r -a filter_tokens <<< "$filter" + cmd+=("${filter_tokens[@]}") + fi + if [[ -s $output_file ]]; then + local capture_path + capture_path=$(<"$output_file") + mkdir -p "$(dirname "$capture_path")" + cmd+=(-w "$capture_path") + fi + if [[ $stdout_mode == "1" ]]; then + "${cmd[@]}" >"$request_dir/stdout" 2>"$request_dir/stderr" || { + write_status "$request_dir" 1 + log_action "packet-capture" "interface=$iface stdout=1" 1 + return + } + else + "${cmd[@]}" >"$request_dir/stdout" 2>"$request_dir/stderr" || { + write_status "$request_dir" 1 + log_action "packet-capture" "interface=$iface stdout=0" 1 + return + } + fi + write_status "$request_dir" 0 + log_action "packet-capture" "interface=$iface stdout=$stdout_mode" 0 +} + +while true; do + shopt -s nullglob + request_dirs=("$queue_dir"/*.req) + shopt -u nullglob + if [[ ${#request_dirs[@]} -eq 0 ]]; then + sleep 1 + continue + fi + for request_dir in "${request_dirs[@]}"; do + [[ -d $request_dir ]] || continue + [[ ! -f "$request_dir/status" ]] || continue + op=$(<"$request_dir/op") + : >"$request_dir/stdout" + : >"$request_dir/stderr" + case "$op" in + apt-install) + run_apt_install "$request_dir" + ;; + packet-capture) + run_packet_capture "$request_dir" + ;; + *) + printf 'unknown operation %s\n' "$op" >"$request_dir/stderr" + write_status "$request_dir" 2 + log_action "$op" "unknown=1" 2 + ;; + esac + done +done diff --git a/tests/capability_repo/.sloptrap b/tests/capability_repo/.sloptrap new file mode 100644 index 0000000..4d663d3 --- /dev/null +++ b/tests/capability_repo/.sloptrap @@ -0,0 +1,3 @@ +name=capability-repo +capabilities=apt-install packet-capture nested-podman +allow_host_network=true diff --git a/tests/invalid_manifest_capabilities/.sloptrap b/tests/invalid_manifest_capabilities/.sloptrap new file mode 100644 index 0000000..180ffb9 --- /dev/null +++ b/tests/invalid_manifest_capabilities/.sloptrap @@ -0,0 +1,4 @@ +name=invalid-capabilities +capabilities=packet-capture not-a-real-capability +codex_args=--sandbox workspace-write --ask-for-approval never +allow_host_network=false diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 83ccc16..3130912 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -319,6 +319,51 @@ run_resume_target() { teardown_stub_env } +run_runtime_context_prompt() { + local scenario_dir="$TEST_ROOT/capability_repo" + printf '==> runtime_context_prompt\n' + setup_stub_env + if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" FAKE_PODMAN_INSPECT_FAIL=1 \ + "$SLOPTRAP_BIN" --trust-capabilities "$scenario_dir" /dev/null 2>&1; then + record_failure "runtime_context_prompt: sloptrap exited non-zero" + teardown_stub_env + return + fi + local login_line run_line + login_line=$(grep "FAKE PODMAN: run " "$STUB_LOG" | head -n 1 || true) + run_line=$(grep "FAKE PODMAN: run " "$STUB_LOG" | tail -n 1 || true) + if [[ -z $run_line || $run_line != *"You are running inside sloptrap"* ]]; then + record_failure "runtime_context_prompt: startup prompt missing from fresh run" + fi + if ! grep -q -- "manifest_present=true" "$STUB_LOG" || ! grep -q -- "requested_capabilities=apt-install nested-podman packet-capture" "$STUB_LOG"; then + record_failure "runtime_context_prompt: runtime summary missing manifest or capability state" + fi + if [[ -n $login_line && $login_line == *"You are running inside sloptrap"* ]]; then + record_failure "runtime_context_prompt: login flow should not receive startup prompt" + fi + teardown_stub_env +} + +run_resume_omits_runtime_context() { + local scenario_dir="$TEST_ROOT/capability_repo" + local session_id="019a81b7-32d2-7622-8639-6698c6579625" + printf '==> resume_omits_runtime_context\n' + setup_stub_env + if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" FAKE_PODMAN_INSPECT_FAIL=1 \ + "$SLOPTRAP_BIN" --trust-capabilities "$scenario_dir" resume "$session_id" /dev/null 2>&1; then + record_failure "resume_omits_runtime_context: sloptrap exited non-zero" + teardown_stub_env + return + fi + if grep -q -- "You are running inside sloptrap" "$STUB_LOG"; then + record_failure "resume_omits_runtime_context: resume should not receive startup prompt" + fi + if ! grep -q -- "codex --sandbox danger-full-access --ask-for-approval never resume $session_id" "$STUB_LOG"; then + record_failure "resume_omits_runtime_context: resume invocation missing" + fi + teardown_stub_env +} + run_auth_file_mount() { local scenario_dir scenario_dir=$(cd "$TEST_ROOT/resume_target" && pwd -P) @@ -508,6 +553,14 @@ run_invalid_manifest_packages() { fi } +run_invalid_manifest_capabilities() { + local scenario_dir="$TEST_ROOT/invalid_manifest_capabilities" + printf '==> invalid_manifest_capabilities\n' + if "$SLOPTRAP_BIN" --dry-run "$scenario_dir" /dev/null 2>&1; then + record_failure "invalid_manifest_capabilities: expected rejection for bad capabilities" + fi +} + run_invalid_allow_host_network() { local scenario_dir="$TEST_ROOT/invalid_allow_host_network" printf '==> invalid_allow_host_network\n' @@ -539,9 +592,6 @@ run_wizzard_create_manifest() { if ! grep -qx "packages_extra=" "$scenario_dir/.sloptrap"; then record_failure "wizzard_create_manifest: packages_extra mismatch" fi - if ! grep -qx "codex_args=--sandbox danger-full-access --ask-for-approval never" "$scenario_dir/.sloptrap"; then - record_failure "wizzard_create_manifest: codex_args mismatch" - fi if ! grep -qx "allow_host_network=false" "$scenario_dir/.sloptrap"; then record_failure "wizzard_create_manifest: allow_host_network mismatch" fi @@ -565,9 +615,6 @@ run_wizzard_existing_defaults() { if ! grep -qx "packages_extra=make git" "$scenario_dir/.sloptrap"; then record_failure "wizzard_existing_defaults: packages_extra not preserved" fi - if ! grep -qx "codex_args=--sandbox workspace-write --ask-for-approval on-request" "$scenario_dir/.sloptrap"; then - record_failure "wizzard_existing_defaults: codex_args not preserved" - fi if ! grep -qx "allow_host_network=true" "$scenario_dir/.sloptrap"; then record_failure "wizzard_existing_defaults: allow_host_network not preserved" fi @@ -597,6 +644,56 @@ run_wizzard_build_trigger() { teardown_stub_env } +run_capability_trust_required() { + local scenario_dir="$TEST_ROOT/capability_repo" + printf '==> capability_trust_required\n' + setup_stub_env + if PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" FAKE_PODMAN_INSPECT_FAIL=1 \ + "$SLOPTRAP_BIN" --enable-capability apt-install "$scenario_dir" /dev/null 2>&1; then + record_failure "capability_trust_required: expected failure without trusted capabilities" + fi + teardown_stub_env +} + +run_capability_profiles() { + local scenario_dir="$TEST_ROOT/capability_repo" + printf '==> capability_profiles\n' + setup_stub_env + if ! PATH="$STUB_BIN:$PATH" HOME="$STUB_HOME" FAKE_PODMAN_LOG="$STUB_LOG" FAKE_PODMAN_INSPECT_FAIL=1 \ + "$SLOPTRAP_BIN" --trust-capabilities --enable-capability apt-install \ + --enable-capability packet-capture --enable-capability nested-podman \ + "$scenario_dir" /dev/null 2>&1; then + record_failure "capability_profiles: sloptrap exited non-zero" + teardown_stub_env + return + fi + if ! grep -q -- "CAPABILITY_PACKAGES=tcpdump podman fuse-overlayfs slirp4netns" "$STUB_LOG"; then + record_failure "capability_profiles: build arg for capability packages missing" + fi + if ! grep -q -- "--cap-add NET_RAW" "$STUB_LOG"; then + record_failure "capability_profiles: NET_RAW capability missing" + fi + if ! grep -q -- "--cap-add NET_ADMIN" "$STUB_LOG"; then + record_failure "capability_profiles: NET_ADMIN capability missing" + fi + if ! grep -q -- "--device /dev/fuse" "$STUB_LOG"; then + record_failure "capability_profiles: /dev/fuse device missing" + fi + if grep -q -- "--read-only" "$STUB_LOG"; then + record_failure "capability_profiles: apt profile should disable read-only rootfs" + fi + if grep -q -- "--user " "$STUB_LOG"; then + record_failure "capability_profiles: capability-enabled run should not force --user" + fi + if ! grep -q -- "SLOPTRAP_ACTIVE_CAPABILITIES=apt-install nested-podman packet-capture" "$STUB_LOG"; then + record_failure "capability_profiles: active capability environment missing" + fi + if ! grep -q -- "SLOPTRAP_INNER_PODMAN_HOST_NETWORK=1" "$STUB_LOG"; then + record_failure "capability_profiles: inner podman host-network mirror flag missing" + fi + teardown_stub_env +} + run_shellcheck run_mount_injection run_root_target @@ -605,6 +702,8 @@ run_manifest_injection run_helper_symlink run_secret_mask run_resume_target +run_runtime_context_prompt +run_resume_omits_runtime_context run_auth_file_mount run_project_state_isolation run_auto_login_empty_auth @@ -617,10 +716,13 @@ run_dotdot_ignore run_invalid_manifest_name run_invalid_manifest_sandbox run_invalid_manifest_packages +run_invalid_manifest_capabilities run_invalid_allow_host_network run_wizzard_create_manifest run_wizzard_existing_defaults run_wizzard_build_trigger +run_capability_trust_required +run_capability_profiles if [[ ${#failures[@]} -gt 0 ]]; then printf '\nTest failures:\n' diff --git a/tests/wizzard_build/.sloptrap b/tests/wizzard_build/.sloptrap index e00e934..5252fde 100644 --- a/tests/wizzard_build/.sloptrap +++ b/tests/wizzard_build/.sloptrap @@ -1,4 +1,3 @@ name=wizzard_build packages_extra= -codex_args=--sandbox danger-full-access --ask-for-approval never allow_host_network=false diff --git a/tests/wizzard_empty/.sloptrap b/tests/wizzard_empty/.sloptrap index 85faf3a..12bbebe 100644 --- a/tests/wizzard_empty/.sloptrap +++ b/tests/wizzard_empty/.sloptrap @@ -1,4 +1,3 @@ name=wizzard_empty packages_extra= -codex_args=--sandbox danger-full-access --ask-for-approval never allow_host_network=false diff --git a/tests/wizzard_existing/.sloptrap b/tests/wizzard_existing/.sloptrap index 777d348..b323158 100644 --- a/tests/wizzard_existing/.sloptrap +++ b/tests/wizzard_existing/.sloptrap @@ -1,4 +1,3 @@ name=custom-wizzard packages_extra=make git -codex_args=--sandbox workspace-write --ask-for-approval on-request allow_host_network=true