Add run-confined skill

Untrusted installers (curl | bash, unvetted npx packages) run in a
transient systemd user unit: $HOME unreadable, only ~/.local writable.
The script is a copy of office-admin/tools/run-confined.sh; it aborts
rather than run unconfined when AppArmor blocks user namespaces.

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-08 07:21:44 -05:00
1 parent 5f9527a8f0
commit ab335b3061
2 files changed
+281

No files matched your search

+214
View File
@@ -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[@]}"