share the host ~/.agents read-only, so skills come along

A sandboxed harness found none of your skills, prompt templates or
standing AGENTS.md, because those live in ~/.agents and only the project
was mounted. It is now bind-mounted at /home/sandbox/.agents by default
-- bound, never copied -- with --no-agents to opt out and --agents-dir
for a curated set, reported by --print-policy and by doctor.

Read-only is the load-bearing part and has no escape hatch: a skill is an
instruction the model follows later, on the host, so a sandbox that could
write ~/.agents had a persistence path out of itself, planted as a
helpful-looking SKILL.md. It holds even under --insecure-keep-root, the
mount lands after /home/sandbox itself so it cannot be shadowed by the
home volume or tmpfs, and an explicit --mount on the same destination
wins because podman refuses duplicates. A missing or unreadable directory
is a silent skip like the pi bridge's; an explicit --agents-dir that
resolves to nothing is a hard error.

The exposure is stated in docs/threat-model.md: the tree, its .git history
and anything pasted into a skill are readable by whatever runs in there.

tests/unit/09_agents.sh covers resolution, precedence and the
bind-never-copy canary; tests/e2e/12_sandbox.sh mounts it in a real
container, checks the host tree survives a run byte for byte and that
even root inside the sandbox cannot write it.

LLM-Generated: true
This commit is contained in:
2026-10-27 20:30:53 -04:00
parent 8ee535f6ae
commit ed2eb462d6
10 changed files with 437 additions and 18 deletions
+123 -10
View File
@@ -3,7 +3,8 @@
# ai-sandbox -- rootless podman sandbox for autonomous agentic development.
#
# One script. It builds a small Ubuntu 24.04 image, then runs any command inside
# it with: only the current directory mounted, no ambient environment variables,
# it with: the current directory mounted (the only writable mount) plus the host
# ~/.agents read-only for skills, no ambient environment variables,
# no root and no capabilities inside the container, hard resource caps, and
# outbound network access limited to an explicit allowlist that the in-container
# process cannot lift.
@@ -41,6 +42,7 @@ AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}"
# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION
# AISB_BASE_TTL_DAYS AISB_BASE_AUTO_UPDATE AISB_BASE_PULL_FALLBACK AISB_SKOPEO
# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS
# AISB_AGENTS AISB_AGENTS_DIR AISB_AGENTS_DEST
AISB_UID="${AISB_UID:-1000}"
AISB_GID="${AISB_GID:-1000}"
# SELinux relabel mode: ':z' (shared label) is what an SELinux host needs; hosts
@@ -65,6 +67,11 @@ sha12() { printf '%s' "$1" | sha256sum | cut -c1-12; }
need() { [[ -n "${2:-}" ]] || die "$1 requires a value (see --help)"; }
trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
# Expand a leading ~ the way a shell would. Config files are not shells, so a
# `agents_dir = ~/.agents` line has to be understood here rather than taken literally.
# shellcheck disable=SC2088 # the literal ~ is the input here: that is what we expand
expand_home() { local p="$1"; [[ "$p" == "~" || "$p" == "~/"* ]] && p="${HOME%/}${p#\~}"; printf '%s' "$p"; }
is_v4() { [[ "${1%%/*}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; }
is_v4cidr() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; }
is_v6() { [[ "$1" == *:* ]]; }
@@ -117,6 +124,18 @@ join() {
printf '%s' "$out"
}
# Destination a `src[:dst[:opt]]` mount spec resolves to, exactly as build_argv mounts
# it. One definition so the .agents bridge can tell whether it has already been mounted
# by hand -- podman refuses a duplicate mount destination outright.
mount_dest() { # mount_dest <spec>
local m="$1" rest dst=""
rest="${m#*:}"
if [[ "$rest" == *:* ]]; then dst="${rest%%:*}"
elif [[ -n "$rest" && "$rest" != "$m" ]]; then dst="$rest"; fi
[[ -n "$dst" ]] || dst="/mnt/$(basename -- "${m%%:*}")"
printf '%s' "$dst"
}
dedup() { # dedup <name-of-array>
local -n _dd_in="$1"
local v out=() seen=" "
@@ -169,13 +188,24 @@ NETWORK
FILESYSTEM
The current directory is bind-mounted at /project and is the working directory.
Nothing else on the host is visible unless you add it.
Nothing else on the host is writable, and nothing else is visible except the one
exception below, which is read-only and there to keep a harness usable.
The rest is opt-in.
--mount PATH[:DEST][:ro] Extra bind mount. Repeatable. Default rw, DEST=/mnt/<name>.
--no-cwd Do not mount the current directory.
--workdir DIR Working directory in the container. Default /project.
--home-cache Persist /home/sandbox in a named volume (default).
--ephemeral-home Discard /home/sandbox on exit.
--read-only Read-only root filesystem (plus tmpfs /tmp).
--agents, --no-agents Share the host ~/.agents (skills, prompts, AGENTS.md) with
the harness read-only at /home/sandbox/.agents, which is
where a harness looks for user-level skills. On by default
whenever that directory exists; --no-agents for a run that
should not see it. Nothing is copied: it is one bind mount.
--agents-dir PATH Which host directory to share (default ~/.agents).
Writable skills would be a way for a sandboxed agent to
write instructions you follow later on the host, so the
mount is read-only and there is no flag to make it rw.
ENVIRONMENT
Inherited by default: TERM LANG LC_ALL LC_CTYPE TZ. Nothing else.
@@ -244,7 +274,8 @@ CONFIG
\$HOME/.config/ai-sandbox/{config,allow} and ./.ai-sandbox/{config,allow}
Every default can also be set as an environment variable:
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ...
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE,
AISB_AGENTS, AISB_AGENTS_DIR, AISB_AGENTS_DEST, ...
Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never),
AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK
(0 forbids the 'podman pull' used to learn a digest when skopeo is absent),
@@ -284,7 +315,7 @@ cfg_read_kv() { # `key = value` file
case "$key" in
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args|base_ttl_days|base_auto_update|\
pi_config|pi_token_env|pi_token_auto|pi_agent_dir) CFG["$key"]="$val" ;;
pi_config|pi_token_env|pi_token_auto|pi_agent_dir|agents|agents_dir|agents_dest) CFG["$key"]="$val" ;;
*) warn "$file: unknown key: $key" ;;
esac
done < "$file"
@@ -322,6 +353,7 @@ OPT_NAME="" OPT_PROXY="" OPT_GATE="" OPT_BASE_IMAGE=""
OPT_NO_CWD=0 OPT_SHELL=0 OPT_DRY=0 OPT_PRINT=0 OPT_NO_BUILD=0 OPT_KEEP=0 OPT_READONLY=0
OPT_HOME_CACHE="" OPT_STRICT="${AISB_STRICT_ARGS:-0}" OPT_UNENFORCED=0 OPT_KEEP_ROOT=0
OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0
OPT_AGENTS="" OPT_AGENTS_DIR=""
OPT_PI_CONFIG="" OPT_PI_TOKEN_AUTO="" OPT_PI_TOKEN_ENV=""
declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=()
COMMAND=()
@@ -329,7 +361,8 @@ COMMAND=()
strict_on() { [[ "${OPT_STRICT:-0}" == 1 || "${AISB_STRICT_ARGS:-0}" == 1 ]]; }
KNOWN_OPTS=(
net allow allow-file proxy no-gate env env-file mount no-cwd workdir home-cache ephemeral-home
net allow allow-file proxy no-gate env env-file mount no-cwd agents no-agents agents-dir
workdir home-cache ephemeral-home
harness install base-image cpus memory pids tmpfs-size name label keep read-only shell
interactive no-tty dry-run print-policy no-build allow-unenforced strict-args insecure-keep-root
print-entrypoint print-containerfile pi-token-env no-pi-config no-pi-token-env help version
@@ -371,6 +404,10 @@ parse_args() {
--mount) need "$1" "${2:-}"; OPT_mount+=("$2"); shift 2 ;;
--mount=*) OPT_mount+=("${1#*=}"); shift ;;
--no-cwd) OPT_NO_CWD=1; shift ;;
--agents) OPT_AGENTS=1; shift ;;
--no-agents) OPT_AGENTS=0; shift ;;
--agents-dir) need "$1" "${2:-}"; OPT_AGENTS_DIR="$2"; shift 2 ;;
--agents-dir=*) OPT_AGENTS_DIR="${1#*=}"; shift ;;
--workdir) need "$1" "${2:-}"; OPT_WORKDIR="$2"; shift 2 ;;
--workdir=*) OPT_WORKDIR="${1#*=}"; shift ;;
--home-cache) OPT_HOME_CACHE=1; shift ;;
@@ -424,6 +461,40 @@ declare -a P_allow=() P_env=() P_mount=() P_install=() P_label=()
cfgv() { local k="$1" d="$2"; printf '%s' "${CFG[$k]:-$d}"; }
# ---------------------------------------------------------------------------
# the .agents bridge
# ---------------------------------------------------------------------------
# `~/.agents` is where user-level skills, prompt templates and standing instructions
# live (pi reads ~/.agents/skills, and so do the other harnesses). A harness with no
# skills is a harness you have to re-brief on every run, so it is shared by default --
# bind-mounted, never copied, and read-only:
#
# * a skill is an instruction the model follows *later*, on the host, outside this
# sandbox. A writable skills directory would be a persistence path out of it, so
# there is no flag to make it writable and none will be added.
# * the contents are never read here, only pointed at: whatever a skill file holds
# (endpoints, placeholder tokens) is visible to the agent exactly as it is on the
# host, which is what "loaded" means and is stated in docs/threat-model.md.
# * a project's own .agents/skills needs no bridge at all: it is inside the cwd mount.
# The host directory to share, or nothing when there is nothing to share. A lookup, not
# a copy: it never reads the contents, and an absent directory is not an error.
agents_src_of() { # -> host path or empty
local d="${POL[agents_dir]}"
[[ -d "$d" && -r "$d" && -x "$d" ]] || return 0
printf '%s' "$d"
}
# True when a `mount` entry already lands on the .agents destination: podman refuses a
# duplicate destination, so an explicit --mount wins over the automatic one.
agents_dest_taken() {
local m
for m in ${P_mount[@]+"${P_mount[@]}"}; do
[[ "$(mount_dest "$m")" == "${POL[agents_dest]}" ]] && return 0
done
return 1
}
resolve_opts() {
POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}"
POL[harness]="${OPT_HARNESS:-${AISB_HARNESS:-$(cfgv harness pi)}}"
@@ -434,7 +505,7 @@ resolve_opts() {
POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}"
POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}"
# --- pi configuration bridge (see pi_config_bridge) -------------------------
POL[pi_agent_dir]="${AISB_PI_AGENT_DIR:-$(cfgv pi_agent_dir "${HOME%/}/.pi/agent")}"
POL[pi_agent_dir]="$(expand_home "${AISB_PI_AGENT_DIR:-$(cfgv pi_agent_dir "${HOME%/}/.pi/agent")}")"
POL[pi_config]="${OPT_PI_CONFIG:-${AISB_PI_CONFIG:-$(cfgv pi_config 1)}}"
case "${POL[pi_config]}" in 0|false|no) POL[pi_config]=0 ;; *) POL[pi_config]=1 ;; esac
POL[pi_token_env]="${OPT_PI_TOKEN_ENV:-${AISB_PI_TOKEN_ENV:-$(cfgv pi_token_env AI_API_TOKEN)}}"
@@ -459,6 +530,13 @@ resolve_opts() {
POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}"
POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}"
POL[home_cache]="${OPT_HOME_CACHE:-${AISB_HOME_CACHE:-$(cfgv home_cache 1)}}"
# --- the .agents bridge (see agents_src_of) ---------------------------------
POL[agents_dir]="$(expand_home "${OPT_AGENTS_DIR:-${AISB_AGENTS_DIR:-$(cfgv agents_dir "${HOME%/}/.agents")}}")"
POL[agents_dest]="${AISB_AGENTS_DEST:-$(cfgv agents_dest /home/sandbox/.agents)}"
POL[agents]="${OPT_AGENTS:-${AISB_AGENTS:-$(cfgv agents 1)}}"
case "${POL[agents]}" in 0|false|no) POL[agents]=0 ;; *) POL[agents]=1 ;; esac
POL[agents_src]=""
[[ "${POL[agents]}" == 1 ]] && POL[agents_src]="$(agents_src_of)"
POL[gate_addr]="${AISB_GATE_ADDR:-$(cfgv gate_addr 100.64.0.1)}"
POL[gate_port]="${AISB_GATE_PORT:-$(cfgv gate_port 18081)}"
POL[extra_args]="${AISB_EXTRA_ARGS:-$(cfgv extra_args '')}"
@@ -502,6 +580,17 @@ validate_opts() {
if [[ "${POL[net]}" == full ]]; then
warn "--net full: the container has unrestricted internet access"
fi
# An explicit --agents-dir that resolves to nothing is a typo, so it is an error. The
# *default* pointing at a directory this host does not have is normal: the bridge
# switches itself off, it does not fail a run.
if [[ "${POL[agents]}" == 1 && -z "${POL[agents_src]}" ]]; then
if [[ -n "${OPT_AGENTS_DIR:-}" ]]; then
die "--agents-dir is not a readable directory: ${POL[agents_dir]}"
fi
[[ "${OPT_AGENTS:-0}" == 1 ]] \
&& warn "--agents: no readable directory at ${POL[agents_dir]} -- the harness starts without your skills"
POL[agents]=0
fi
if [[ "${POL[net]}" == allow && ${#P_allow[@]} -eq 0 && -z "${POL[proxy]}" ]]; then
die "no egress allowlist configured. Refusing to start wide open.
add one: $PROG --allow aipi.reeseapps.com --env AI_API_TOKEN
@@ -1527,13 +1616,24 @@ build_argv() {
fi
[[ "${POL[readonly]}" == 1 ]] && ARGV+=(--read-only)
# --- the .agents bridge -----------------------------------------------------
# Inside the home mount (a volume or a tmpfs), which is why it comes after it. ro is
# not configurable: see agents_src_of. A hand-written --mount on the same destination
# wins, because podman refuses duplicates and the explicit one is the reviewed one.
if [[ -n "${POL[agents_src]:-}" ]]; then
if agents_dest_taken; then
log "agents: not shared, ${POL[agents_dest]} is already a --mount destination"
else
ARGV+=(-v "${POL[agents_src]}:${POL[agents_dest]}:ro${SELOPT_SEP}")
fi
fi
local m src dst opt rest
for m in ${P_mount[@]+"${P_mount[@]}"}; do
src="${m%%:*}"; rest="${m#*:}"; dst=""; opt=""
if [[ "$rest" == *:* ]]; then dst="${rest%%:*}"; opt="${rest##*:}";
elif [[ -n "$rest" && "$rest" != "$m" ]]; then dst="$rest"; fi
src="${m%%:*}"; rest="${m#*:}"; opt=""
if [[ "$rest" == *:* ]]; then opt="${rest##*:}"; fi
dst="$(mount_dest "$m")"
[[ -e "$src" ]] || die "--mount: path does not exist: $src"
[[ -n "$dst" ]] || dst="/mnt/$(basename -- "$src")"
[[ -z "$opt" || "$opt" == ro || "$opt" == rw ]] || die "--mount: option must be ro or rw (got '$opt')"
if [[ -n "$opt" ]]; then ARGV+=(-v "${src}:${dst}:${opt}${SELOPT_SEP}")
else ARGV+=(-v "${src}:${dst}${SELOPT}"); fi
@@ -1595,6 +1695,8 @@ print_policy() {
printf 'env_names=%s\n' "$(join ' ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})"
printf 'env_missing=%s\n' "$(join ' ' ${MISSING_ENV[@]+"${MISSING_ENV[@]}"})"
printf 'mounts=%s\n' "$(join ';' ${P_mount[@]+"${P_mount[@]}"})"
printf 'agents=%s\n' "${POL[agents_src]:-off}"
if [[ -n "${POL[agents_src]:-}" ]]; then printf 'agents_dest=%s\n' "${POL[agents_dest]}"; fi
}
# ===========================================================================
@@ -1755,6 +1857,17 @@ cmd_doctor() {
chk WARN "pi credential: \$$ptok is unset or empty -- a bridged pi run is refused (export it, add it to $(cfg_or_home), or use --no-pi-config)"
fi
fi
# The .agents bridge, reported read-only like the pi bridge: doctor never mounts.
if [[ -z "${POL[agents_src]:-}" ]]; then
chk INFO "agents: your skills are not shared (agents=0, --no-agents, or no ${POL[agents_dir]})"
else
local nsk; nsk="$(find "${POL[agents_src]}" -maxdepth 3 -name SKILL.md 2>/dev/null | wc -l | tr -d ' ')"
chk INFO "agents: ${POL[agents_src]} -> ${POL[agents_dest]} read-only (${nsk} skill file(s))"
if [[ -n "${POL[selinux]:-}" && "${POL[agents_src]}" == "$HOME"* ]]; then
chk INFO "agents: relabelled '${POL[selinux]}' for the container, like the project mount -- read-only either way"
fi
fi
if ((${#P_allow[@]})); then
chk INFO "allowlist from config/env: ${P_allow[*]}"
else