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:
1 parent
5f9527a8f0
commit
ab335b3061
2 files changed
+281
No files matched your search
@@ -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`.
|
||||||
Executable
+214
@@ -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[@]}"
|
||||||
Reference in new issue
Block a user