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:
+123
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user