diff --git a/run-confined/SKILL.md b/run-confined/SKILL.md new file mode 100644 index 0000000..4834214 --- /dev/null +++ b/run-confined/SKILL.md @@ -0,0 +1,67 @@ +--- +name: run-confined +description: Run confined / sandbox an untrusted command — use for `curl ... | bash` installers, npm/npx/bunx of unvetted packages, or any third-party script that should not see ~/.ssh, ~/.config/gh, tokens, or the rest of $HOME; when someone says "run it confined", "sandbox this", or "don't pipe that into my shell". Runs the command in a transient systemd user unit whose only writable place is ~/.local. +--- + +# Run confined: untrusted code in a one-shot systemd user unit + +[run-confined.sh](run-confined.sh) runs a command in a transient +`systemd-run --user` service with kernel-enforced sandboxing: + +- `$HOME` is an unreadable tmpfs (`ProtectHome=yes`): no `~/.ssh`, + `~/.config/gh`, `.env` files, or other secrets. +- The only writable place is `/tmp/confined`, a bind mount of `~/.local`; + inside the unit `$HOME` points there. Whatever gets installed lands in + `~/.local` on the host. +- System dirs are read-only; `/tmp` and `/dev` are private. +- The calling shell's environment is not inherited (no `GH_TOKEN`, + `SSH_AUTH_SOCK`, ...). Network is allowed. +- Node (newest nvm v22, bound read-only) is on `PATH` when present. + +Before running anything, the script does a wet test: if `~/.ssh` is still +visible from inside a probe unit, it aborts with exit 3 and runs nothing. + +## Usage + +```sh +.agents/skills/run-confined/run-confined.sh -- cmd [args...] +``` + +Arguments naming paths under `$HOME` or `~/.local` are rewritten to the +`/tmp/confined` equivalents; add `--preserve-paths` to opt out. + +stdin, stdout, and stderr are piped through (`systemd-run -P`), so a +download-and-run installer works as a pipe. Fetch with `curl` on the host +(read it first), then run the interpreter confined. Pass environment +explicitly with `env`, since the unit doesn't inherit any: + +```sh +curl -fsSL https://bun.com/install -o /tmp/bun-install.sh +less /tmp/bun-install.sh +.agents/skills/run-confined/run-confined.sh -- \ + env BUN_INSTALL=/tmp/confined bash < /tmp/bun-install.sh +# -> ~/.local/bin/bun on the host +``` + +Point installers at `/tmp/confined` (e.g. `BUN_INSTALL`, `npm_config_prefix`, +which the script already sets), not `$HOME/.foo`. That way binaries land in +`~/.local/bin`, which is already on the host `PATH`. Rc-file edits an +installer makes go to `~/.local/.bashrc` and similar, where they have no +effect; check for them and delete them. + +## When the wet test aborts + +On Ubuntu 24.04, `kernel.apparmor_restrict_unprivileged_userns=1` (the +default) stops the systemd user manager from creating namespaces, and +systemd *silently* skips the sandbox. The script refuses rather than run +unconfined. The operator decides whether to lift it: + +```sh +sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 # until reboot +``` + +Don't run the command unconfined as a fallback. Report the abort and +let the operator choose. The script header lists the full symptom set. + +The canonical copy of this script is +`~/projects/office-admin/tools/run-confined.sh`. diff --git a/run-confined/run-confined.sh b/run-confined/run-confined.sh new file mode 100755 index 0000000..64cb619 --- /dev/null +++ b/run-confined/run-confined.sh @@ -0,0 +1,214 @@ +#!/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[@]}" \ No newline at end of file