#!/bin/bash # Run a command inside a one-shot, kernel-confined systemd USER unit. # # Usage: run-confined.sh -- cmd [args...] # # Everything after the first "--" is executed in a transient user service # with strong sandboxing. Inside the unit: # * $HOME is an unreadable tmpfs - ~/.ssh, ~/.config/gh, CI secrets # do not exist / cannot be read. Denial by kernel namespace. # * the only writable place is /tmp/confined, which is a bind mount of # ~/.local, and $HOME points there. Installs for this machine land in # ~/.local and stay there. # * system dirs are read-only; /dev and /tmp are private. # # The unit is started by the systemd user manager, so the calling shell's # environment is NOT inherited (no GH_TOKEN, SSH_AUTH_SOCK, etc.) unless # explicitly passed via Environment= below. # # Requires the user manager to be able to create mount/user namespaces. # Ubuntu 24.04 ships kernel.apparmor_restrict_unprivileged_userns=1 which # blocks that; run-confined refuses to run unless the sandbox is proven # effective by a wet test, so it never silently runs unconfined. # # TROUBLESHOOTING # -------------- # On Ubuntu 24.04 the AppArmor userns boundary ( # kernel.apparmor_restrict_unprivileged_userns=1 , set by default) stops # the systemd user manager from creating namespaces. systemd then SILENTLY # skips sandbox setup rather than failing the unit, so the command still # "runs" while the sandbox is off. Symptoms: # * wet-test abort with a leak report that lists the full $HOME (incl. # ~/.ssh), instead of an empty / "Permission denied" home. # * "UTS namespace setup is prohibited (container manager?), ignoring # namespace setup." in `journalctl --user -b`. # * units die at once with status=218/CAPABILITIES + journal line # "Failed to drop capabilities: Operation not permitted" when any of # PrivateDevices/ProtectClock/ProtectHostname/ProtectKernel* is set. # * unrelated sandboxes (firejail, unshare -m, podman rootless) fail too. # None of this means the box is broken; it is a deliberate AppArmor # containment boundary. To use namespaced sandboxes again, either # temporarily with: # sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 # or persist it across reboots with: # echo 'kernel.apparmor_restrict_unprivileged_userns=0' \ # | sudo tee /etc/sysctl.d/60-userns.conf # # Some hardening properties need capset() / extra namespaces and are not # available on every kernel; each is probed and dropped with a note. set -euo pipefail usage() { sed -n '3,9p' "$0" exit 2 } [[ $# -ge 1 && $1 == "--" ]] || usage shift [[ $# -ge 1 ]] || usage carve="/tmp/confined" writerowed="$HOME/.local" wet_marker=".run-confined-wet" # Command args that reference files under $HOME are normally tab-completed # on the host, but inside the unit $HOME is blanked and the carve lives at # $carve. Rewrite such args so you type the host path and the unit still # finds it. Pass the literal directory with "--preserve-paths" to opt out. preserve_paths=0 for a in "$@"; do [ "$a" = "--preserve-paths" ] && preserve_paths=1 done map_path() { case "$1" in "$writerowed"/*) printf '%s/%s' "$carve" "${1#"$writerowed"/}" ;; "$writerowed") printf '%s' "$carve" ;; "$HOME"/*) printf '%s/%s' "$carve" "${1#"$HOME"/}" ;; "$HOME") printf '%s' "$carve" ;; *) printf '%s' "$1" ;; esac } cmd=() for a in "$@"; do if [ "$a" = "--preserve-paths" ]; then continue elif [ "$preserve_paths" -eq 1 ]; then cmd+=("$a") else cmd+=("$(map_path "$a")") fi done [[ ${#cmd[@]} -ge 1 ]] || usage # Node for the unit. No downloads, no checksum theater: the newest nvm # v22 install is bound in READ-ONLY (so the unit gets node+npm but cannot # tamper with ~/.nvm). Override with CONFINED_NODE=/path/to/node-dir (the # dir that has bin/node). A standalone carve node is preferred if present. unit_path="/usr/local/bin:/usr/bin:/bin" bind_node_dir="" node_target="/tmp/confined-node" if [[ -x "$HOME/.local/share/pi-node/current/bin/node" ]]; then unit_path="$HOME/.local/share/pi-node/current/bin:$unit_path" elif [[ -n "${CONFINED_NODE:-}" && -x "$CONFINED_NODE/bin/node" ]]; then bind_node_dir="$CONFINED_NODE" unit_path="$node_target/bin:$unit_path" else newest_bin=$(ls -d "$HOME"/.nvm/versions/node/v22.*/bin 2>/dev/null | sort -V | tail -1) if [[ -n "$newest_bin" && -x "$newest_bin/node" ]]; then bind_node_dir="${newest_bin%/bin}" unit_path="$node_target/bin:$unit_path" fi fi base_props=( "ProtectSystem=strict" "ProtectHome=yes" "BindPaths=$writerowed:$carve" "PrivateTmp=yes" "PrivateMounts=yes" "ProtectKernelTunables=yes" "ProtectControlGroups=yes" "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK" "RestrictRealtime=yes" "RestrictSUIDSGID=yes" "LockPersonality=yes" "NoNewPrivileges=yes" "MemoryMax=1G" "TasksMax=200" "TimeoutStartSec=infinity" ) # capset-dependent; known to fail under some kernels -> probe and drop. optional_props=( "PrivateDevices=yes" "ProtectClock=yes" "ProtectHostname=yes" "ProtectKernelModules=yes" "ProtectKernelLogs=yes" ) probe_pass() { timeout 10 systemd-run --user --wait -q --property="$1" -- /bin/true 2>/dev/null } # A real sandbox blanks $HOME (unreadable), exposes only the carve, and # allows writes there. Concretely: $HOME/.ssh must not be listable, and a # touch/rm round-trip must work inside the carve. Anything else means # systemd silently skipped the namespace setup -> not confined. sandbox_wet_test() { timeout 20 systemd-run --user --wait -q \ --property="ProtectHome=yes" \ --property="BindPaths=$writerowed:$carve" \ --property="PrivateTmp=yes" \ --property="Environment=HOME=$carve" \ -- /bin/sh -c "ls -d \"\$HOME/.ssh\" >/dev/null 2>&1 && exit 1; touch \"$carve/$wet_marker\" && rm \"$carve/$wet_marker\"" 2>/dev/null } # Show what leaked, to diagnose an ineffective sandbox. sandbox_leak_report() { timeout 20 systemd-run --user --wait -P \ --property="ProtectHome=yes" \ --property="BindPaths=$writerowed:$carve" \ --property="PrivateTmp=yes" \ --property="Environment=HOME=$carve" \ -- /bin/sh -c 'echo "HOME listing (expect Permission denied):"; ls -A "$HOME" 2>&1; echo; echo ".ssh (expect cannot access):"; ls -d "$HOME/.ssh" 2>&1; echo; echo "carve:"; ls "$carve" 2>&1 | head -5' 2>/dev/null } props=("${base_props[@]}") if [[ -n "$bind_node_dir" ]]; then props+=("BindReadOnlyPaths=$bind_node_dir:$node_target") fi for p in "${optional_props[@]}"; do if probe_pass "$p"; then props+=("$p") else echo "note: dropping unsupported hardening property: $p" >&2 fi done if ! sandbox_wet_test; then echo "abort: sandbox is not actually enforced on this kernel/session" >&2 echo " ~/.ssh must not exist inside the transient unit and the carve" >&2 echo " write must succeed; the wet test failed, so NOTHING ran." >&2 echo " This is the apparmor_restrict_unprivileged_userns=1 symptom on" >&2 echo " Ubuntu 24.04 (see the header for the full symptom list). Fix:" >&2 echo " sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0" >&2 echo " or persist: echo 'kernel.apparmor_restrict_unprivileged_userns=0' |" >&2 echo " sudo tee /etc/sysctl.d/60-userns.conf" >&2 echo "Leak report from the probe unit follows:" >&2 sandbox_leak_report >&2 || true exit 3 fi unit_name="run-confined-$(date +%s%N)" systemd_run_args=( --user --wait -P --unit="$unit_name" --property="Type=oneshot" --property="WorkingDirectory=$carve" --property="Environment=HOME=$carve" --property="Environment=PATH=$unit_path" --property="Environment=TMPDIR=${TMPDIR:-/tmp}" --property="Environment=TERM=vt100" --property="Environment=npm_config_prefix=$carve" --property="Environment=npm_config_cache=$carve/share/node/npm-cache" ) for p in "${props[@]}"; do systemd_run_args+=(--property="$p") done systemd-run "${systemd_run_args[@]}" -- "${cmd[@]}"