From ed2eb462d6ffa7346a141dedfe65795acc60403c Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 27 Oct 2026 20:30:53 -0400 Subject: [PATCH] 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 --- AGENTS.md | 8 ++- PLAN.md | 18 ++++- README.md | 30 +++++++- bin/ai-sandbox | 133 ++++++++++++++++++++++++++++++++--- config/config.example | 16 +++++ docs/recipes.md | 31 ++++++++- docs/threat-model.md | 16 ++++- tests/e2e/12_sandbox.sh | 47 +++++++++++++ tests/run.sh | 5 ++ tests/unit/09_agents.sh | 151 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 tests/unit/09_agents.sh diff --git a/AGENTS.md b/AGENTS.md index 9a3a17c..261d018 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,16 @@ Working notes for anyone (human or agent) changing this repository. One bash script, `bin/ai-sandbox`, that runs another program — usually an LLM coding harness — inside a rootless podman container with a strict, explicit policy: -only the current directory mounted, no inherited environment, no root or +the current directory as the only writable mount plus the host `~/.agents` read-only +(so the harness still has its skills), no inherited environment, no root or capabilities inside the container, resource caps, and egress limited to an allowlist that the sandboxed process cannot lift. +The `.agents` share is the one exception to "nothing but the project directory", so it is +read-only with no escape hatch (`agents`/`--agents`/`--agents-dir`, and `--no-agents`): +a sandbox that could write your skills could write instructions you follow later on the +host. See `agents_src_of()` and the tests in `tests/unit/09_agents.sh` / `tests/e2e/12_sandbox.sh`. + ## Non-negotiables 1. **Single script.** The Containerfile, the container entrypoint, the squid diff --git a/PLAN.md b/PLAN.md index 1eb3b70..e6cf0a1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -110,6 +110,23 @@ reach its model, because "sandbox is broken" is a worse bug report than an expli refusal. And the bridge never widens the allowlist; it warns with the exact `--allow` to add, since an agent silently unable to answer looks like a network failure. +### 10 — The `.agents` share +A sandboxed harness found none of your skills, prompt templates or standing `AGENTS.md`, +because those live in `~/.agents` and only the project is mounted. It is now bind-mounted +read-only at `/home/sandbox/.agents` by default (`agents`, `agents_dir`, `agents_dest`; +`--agents`, `--no-agents`, `--agents-dir`), which is where pi looks for user-level skills. +Bound, never copied, so the sandbox always sees the live tree and nothing of it lands in +ai-sandbox state. + +Read-only is the load-bearing part and there is deliberately no way to lift it: 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` (e2e-checked), the mount is nested +inside the home volume *after* it so it cannot be shadowed, 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. + ## Follow-ups (not yet done) * **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container @@ -123,7 +140,6 @@ to add, since an agent silently unable to answer looks like a network failure. * **Quarantine mode for untrusted repos**: `--net none` + read-only mount + `--ephemeral-home` as a single flag, plus a warning if `.git` hooks exist. * **`--record`**: optional pcap/CONNECT-log capture of a session for review. -* **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount. * Packaging: a `quadlet`/systemd unit for a long-lived named gate. * Signature verification for the base where the registry signs it (cosign/sigstore), so a pin is authenticated rather than merely immutable; and an optional local diff --git a/README.md b/README.md index d14d536..48a6254 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ Asking for a variable that is not set fails the run closed. ## Filesystem -Your cwd is mounted at `/project` and is the working directory — that is the whole -default. Everything else is opt-in: +Your cwd is mounted at `/project` and is the working directory. That is the only +*writable* mount, and nothing else on the host is visible except the one exception +below. Everything else is opt-in: ```bash --mount ~/.ssh:ro # if the agent must be able to push @@ -115,6 +116,31 @@ default. Everything else is opt-in: Files the agent writes land on your disk owned by **your** uid, not root (`--userns=keep-id`, verified by `doctor`). +### Your `.agents` is shared, read-only + +A harness with no skills is a harness you have to re-brief on every run, so `~/.agents` +— skills, prompt templates, your standing `AGENTS.md` — is bind-mounted at +`/home/sandbox/.agents`, which is where pi looks for user-level skills. Nothing is +copied; it is one mount, and it is **read-only**: + +```bash +pi # your skills are there +pi --no-agents # a run where the harness sees none of them +pi --agents-dir ~/work/.agents # a different set, e.g. per-team +``` + +Read-only is not a convenience limit. A skill is an instruction the model follows +*later*, on the host, outside the sandbox — so a sandbox that could edit `~/.agents` +had a persistence path out of itself, planted as a helpful-looking `SKILL.md`. There is +no flag to make it writable, and the `ro` holds even with `--insecure-keep-root`. + +A project's own `.agents/` needs no setting at all: it is inside the project mount, and +harnesses that read both (`pi` reads `~/.agents/skills` and `.agents/skills`) see the +project's next to yours. What that costs in exposure is spelled out in +[`docs/threat-model.md`](docs/threat-model.md): whatever is in those files — including +any token someone pasted into a skill — is now readable by the agent you are sandboxing. +Run `--no-agents` if a set of skills is not one you would hand to a stranger. + ## Pi inside the sandbox The image has `pi` baked in, and its home is a volume, so out of the box the sandboxed diff --git a/bin/ai-sandbox b/bin/ai-sandbox index 7402b42..77e3eee 100755 --- a/bin/ai-sandbox +++ b/bin/ai-sandbox @@ -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 + 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 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/. --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 diff --git a/config/config.example b/config/config.example index 63dfdd7..5812768 100644 --- a/config/config.example +++ b/config/config.example @@ -65,6 +65,22 @@ net = allow # Where to read the host config from (default ~/.pi/agent). # pi_agent_dir = ~/.pi/agent +# --- skills and standing instructions --------------------------------------- +# Your host ~/.agents is bind-mounted read-only at /home/sandbox/.agents, which is +# where pi (and the other harnesses) look for user-level skills, prompt templates and +# standing instructions. Read-only, and there is no way to make it writable: a skill is +# an instruction the model follows later, on the host, so a sandbox that could write one +# would have a persistence path out of itself. 0 = your skills stay at home. +# agents = 1 +# +# Which directory to share ('~' is expanded). A project's own .agents/ needs no setting +# here: it comes in with the project mount, alongside this one. +# agents_dir = ~/.agents +# +# Where it lands in the container. Keep it under HOME -- that is the only place a +# harness looks for user-level skills. +# agents_dest = /home/sandbox/.agents + # --- behaviour ------------------------------------------------------------- # SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable. # Defaults to ':z' when getenforce exists. diff --git a/docs/recipes.md b/docs/recipes.md index e27a9a7..18ed5a3 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -57,6 +57,33 @@ pi --no-pi-config --allow aipi.reeseapps.com Edits made inside the sandbox are kept; changes to the host config are picked up on the next run; deleting `~/.pi/agent/models.json` inside the sandbox re-seeds it. +## Your skills and standing instructions + +`~/.agents` is shared read-only at `/home/sandbox/.agents` by default, so the sandboxed +harness finds the same skills, prompt templates and `AGENTS.md` you use on the host. +A project's own `.agents/` needs nothing: it arrives with the project mount, and harnesses +that read both see the project's alongside yours. + +```bash +# the everyday case: your skills are simply there +pi --allow aipi.reeseapps.com + +# a run where the harness should not have them: reviewing untrusted code, or a +# skills set that mentions internal hosts you would rather it not repeat back +pi --net none --no-agents -- bash -lc 'make lint' + +# a curated set for sandboxes -- e.g. host skills stay yours, this dir is what you share +pi --agents-dir ~/.config/ai-sandbox/agents --allow aipi.reeseapps.com + +# two sets, per trust level, chosen by config file in a repo +# .ai-sandbox/config: agents_dir = ./.agents-shared +``` + +It is read-only and there is no flag to change that: a skill is an instruction you +follow on the host later, so letting a sandbox write one would let it persist past the +run. If a workflow really needs to *author* skills, do it in the project directory and +move the result into `~/.agents` yourself, where you can read it first. + ## Per harness ```bash @@ -126,8 +153,8 @@ pi --allow aipi.reeseapps.com --mount ../docs:/docs:ro # nothing persistent, nothing readable, nothing writable outside the repo pi --net none --ephemeral-home --read-only -- make build -# an entirely blank sandbox for untrusted code -pi --net none --no-cwd --ephemeral-home --read-only -- bash +# an entirely blank sandbox for untrusted code: no project, no home, no skills +pi --net none --no-cwd --ephemeral-home --read-only --no-agents -- bash # CI-ish: refuse to start if the policy or environment is not exactly right pi --strict-args --net allow --allow-file .ai-sandbox/allow --env-file .ci/env -- npm ci diff --git a/docs/threat-model.md b/docs/threat-model.md index fdd4a26..c4b5dc2 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -19,7 +19,8 @@ registry you don't control, or someone with access to your host user account. | Attack | Stopped by | Evidence | |---|---|---| -| Editing / deleting anything outside the project directory | only `$PWD` is mounted; the rest of the host is not in the mount namespace | `tests/e2e/12_sandbox.sh` | +| Editing / deleting anything outside the project directory | `$PWD` is the only writable mount; the rest of the host is not in the mount namespace | `tests/e2e/12_sandbox.sh` | +| Rewriting your skills, prompts or global `AGENTS.md` | `~/.agents` is mounted, but read-only, and there is no flag to change that | `tests/e2e/12_sandbox.sh` | | Reading `~/.ssh`, `~/.aws`, `~/.config/gcloud`, browser profiles | same as above; those paths do not exist in the container | `tests/e2e/12_sandbox.sh` | | Exfiltrating your environment (API keys for *other* services) | nothing is passed through unless named with `--env` | `tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh` | | Passing secrets through the process list to other users | values travel in a `0600` file, never in argv | `tests/e2e/12_sandbox.sh` | @@ -70,7 +71,18 @@ Be honest about these, and design your work accordingly. 10. **Rootless podman still means shared kernel and shared UID namespace.** Files the container writes are owned by *you*. There is no uid boundary between "agent's file" and "your file" beyond ownership metadata and your own discipline. -11. **The pi bridge reads a file that may hold a live key.** It rewrites `apiKey` to +11. **Your `~/.agents` is readable inside the sandbox.** This is a deliberate + exception to "only your project directory is mounted", because a harness without + your skills is a harness you re-brief every run: `~/.agents` arrives read-only at + `/home/sandbox/.agents` (`--no-agents` for a run without it, `--agents-dir` for a + curated set). Read-only means the agent cannot write a `SKILL.md` that you will + follow tomorrow on the host — that is the part that matters, and it holds even under + `--insecure-keep-root`. It does *not* mean the contents are secret: the whole tree, + including its `.git` history and any token someone pasted into a skill, is readable + by whatever you run in there. Treat a shared skill directory as published to the + agent, lint it for secrets (`skill-lint`), and keep credentials in the environment + you pass explicitly rather than in a file that is mounted everywhere. +12. **The pi bridge reads a file that may hold a live key.** It rewrites `apiKey` to an environment reference and never copies `auth.json`, but the *endpoint* in your `models.json` becomes an endpoint the sandbox can be pointed at, and the token it uses is your real one. So the bridged config is a convenience, not a narrower diff --git a/tests/e2e/12_sandbox.sh b/tests/e2e/12_sandbox.sh index 77bd335..3c5994a 100644 --- a/tests/e2e/12_sandbox.sh +++ b/tests/e2e/12_sandbox.sh @@ -102,6 +102,53 @@ assert_contains "cached" "the home cache volume persists across runs" sb $DARK --ephemeral-home -- bash -c 'test -f "$HOME/cached-marker" && echo LEAK || echo isolated' assert_contains "isolated" "an ephemeral home does not see it" +t_name "the host ~/.agents arrives as \$HOME/.agents, read-only" +AG="$WORK/host-agents" +mkdir -p "$AG/skills/demo-skill" "$AG/prompts" +printf 'name: demo-skill\ninstructions: always use the demo skill\n' > "$AG/skills/demo-skill/SKILL.md" +printf 'a prompt template\n' > "$AG/prompts/demo-prompt.md" +printf '# standing instructions\n' > "$AG/AGENTS.md" +export AISB_AGENTS_DIR="$AG" + +sb $DARK -- bash -c 'ls -A "$HOME/.agents" | tr "\n" " "; echo; cat "$HOME/.agents/skills/demo-skill/SKILL.md"; cat "$HOME/.agents/AGENTS.md"' +assert_contains "skills" "the skills directory is where a harness looks for user-level skills" +assert_contains "always use the demo skill" "a SKILL.md is readable at the real path" +assert_contains "# standing instructions" "and so is the global AGENTS.md" + +sb $DARK -- bash -c ' + touch "$HOME/.agents/skills/demo-skill/injected" 2>/dev/null && echo "BAD: skills writable" || echo skills_locked=ok + mkdir "$HOME/.agents/evil-skill" 2>/dev/null && echo "BAD: could add a skill" || echo add_locked=ok + touch "$HOME/scratch-still-works" && echo home_still_writable +' +assert_contains "skills_locked=ok" "a sandboxed agent cannot rewrite the skills it is reading" +assert_contains "add_locked=ok" "nor plant a new one" +assert_contains "home_still_writable" "while the rest of home keeps working" +sb $DARK --insecure-keep-root -- bash -c 'touch "$HOME/.agents/skills/demo-skill/injected" 2>/dev/null && echo "BAD: writable as root" || echo root_still_locked' +assert_contains "root_still_locked" "not even --insecure-keep-root gets to write your skills" +BEFORE="$(find "$AG" | sort)" +sb $DARK -- bash -c 'echo pwned >> "$HOME/.agents/AGENTS.md" 2>/dev/null; rm -f "$HOME/.agents/prompts/demo-prompt.md" 2>/dev/null; true' +AFTER="$(find "$AG" | sort)" +assert_eq "$BEFORE" "$AFTER" "the host directory is byte-for-byte untouched by the run" +grep -q pwned "$AG/AGENTS.md" && _bad "appends from the container do not land on the host" || _ok "appends from the container do not land on the host" + +sb $DARK --ephemeral-home -- bash -c 'cat "$HOME/.agents/skills/demo-skill/SKILL.md"' +assert_contains "always use the demo skill" "a tmpfs home still gets the mount nested inside it" +sb $DARK --read-only -- bash -c 'cat "$HOME/.agents/AGENTS.md"' +assert_contains "# standing instructions" "and so does a read-only root filesystem" +# podman creates the mount point inside the home volume, so the directory can outlive a +# run that shared it; what --no-agents guarantees is that none of the host is in it. +sb $DARK --no-agents -- bash -c 'ls -A "$HOME/.agents" 2>/dev/null | tr "\n" " "; test -e "$HOME/.agents/skills/demo-skill" && echo PRESENT || echo ABSENT' +assert_contains "ABSENT" "--no-agents leaves no host skill visible in the container" +assert_absent "demo-skill" "not even as a directory name" + +# a project's own .agents needs no bridge: it is inside the project mount. Both are +# visible at once, which is what a harness that reads both (pi) expects. +mkdir -p "$WORK_DIR/.agents/skills/project-skill" +printf 'name: project-skill\ninstructions: project only\n' > "$WORK_DIR/.agents/skills/project-skill/SKILL.md" +sb $DARK -- bash -c 'cat /project/.agents/skills/project-skill/SKILL.md; ls "$HOME/.agents/skills" | tr "\n" " "' +assert_contains "project only" "the project's own .agents comes in with the project mount" +assert_contains "demo-skill" "and the user-level one is still alongside it" + t_name "read-only root filesystem" sb $DARK --read-only -- bash -c 'touch /usr/bin/nope 2>/dev/null && echo "BAD: wrote to rootfs" || echo rootfs_locked=ok; touch /project/rw-still-works && echo project_still_writable' assert_contains "rootfs_locked=ok" "--read-only blocks writes outside mounts" diff --git a/tests/run.sh b/tests/run.sh index ac5ea0e..0af2555 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -75,9 +75,14 @@ for f in "${FILES[@]}"; do # Never bridge the developer's own ~/.pi/agent into a test: tests that exercise the # bridge point AISB_PI_AGENT_DIR at a fixture of their own. Same for a real token. export AISB_PI_AGENT_DIR="${AISB_PI_AGENT_DIR:-$W/absent-pi-agent-dir}" + # Same for ~/.agents: its default is the developer's own skills directory, which no + # test should depend on or accidentally share. Files that exercise the .agents + # bridge point AISB_AGENTS_DIR at a fixture of their own. + export AISB_AGENTS_DIR="${AISB_AGENTS_DIR:-$W/absent-agents-dir}" unset AI_API_TOKEN AISB_PI_TOKEN_ENV AISB_PI_CONFIG AISB_PI_TOKEN_AUTO # NOTE: HOME is deliberately not overridden -- podman storage lives there. unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE + unset AISB_AGENTS AISB_AGENTS_DEST source "$f" summary ) || FAILED_FILES+=("$name") diff --git a/tests/unit/09_agents.sh b/tests/unit/09_agents.sh new file mode 100644 index 0000000..9b1c015 --- /dev/null +++ b/tests/unit/09_agents.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# tests/unit/09_agents.sh -- sharing the host ~/.agents (skills, prompts, AGENTS.md). +# +# The bridge binds and never copies, so the two things worth pinning down are that the +# mount is there when it should be, and that it is read-only: a harness that could edit +# your skills could edit the instructions you follow tomorrow on the host. Everything is +# preview, so no container is started -- the real mount is checked in e2e/12_sandbox.sh. +source "$TESTS/lib.sh" + +FIX="$WORK/agents" +mkdir -p "$FIX/skills/demo-skill" "$FIX/prompts" +CANARY_SKILL='SKILLCANARY-not-a-file-ai-sandbox-writes' +printf 'name: demo-skill\n%s\n' "$CANARY_SKILL" > "$FIX/skills/demo-skill/SKILL.md" +printf '# global instructions\n' > "$FIX/AGENTS.md" +export AISB_AGENTS_DIR="$FIX" +unset AISB_AGENTS AISB_AGENTS_DEST + +PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1 +MOUNT_RE="$FIX:/home/sandbox/.agents:ro" + +# The last line of --dry-run is the podman invocation; the order of the -v/--mount +# arguments inside it is what decides whether the mount survives being nested in the +# home volume, so it is part of what these tests check. +inv() { printf '%s' "$OUT" | tail -1; } +has_mount() { inv | grep -qF -- "$MOUNT_RE"; } + +t_name "shared by default, read-only, where a harness looks for user skills" +sb --dry-run --net none +assert_rc 0 "dry run" +assert_contains "$FIX:/home/sandbox/.agents" "the host directory is mounted at \$HOME/.agents" +assert_contains "$MOUNT_RE" "read-only" +assert_absent "$FIX:/home/sandbox/.agents:rw" "and there is no rw form of it" +if has_mount; then + after="$(inv | sed "s|.*type=volume,source=[^ ]*target=/home/sandbox||")" + [[ "$after" == *"$MOUNT_RE"* ]] && _ok "mounted after /home/sandbox itself, so it is not shadowed" \ + || _bad "mounted after /home/sandbox itself, so it is not shadowed" +else + _bad "mounted after /home/sandbox itself, so it is not shadowed" "no agents mount at all" +fi +assert_absent "$FIX/skills" "the directory itself is mounted, not a cherry-picked skills/" +assert_absent "$FIX/AGENTS.md:" "nor an AGENTS.md singled out" + +t_name "it is a bind mount, never a copy" +sb --keep --print-policy --net none +if grep -rlF "$CANARY_SKILL" "$AISB_STATE_DIR" "$AISB_CACHE_DIR" > /dev/null 2>&1; then + _bad "no skill content is copied into ai-sandbox state" \ + "found the canary under $AISB_STATE_DIR or $AISB_CACHE_DIR" +else + _ok "no skill content is copied into ai-sandbox state" +fi +if find "$AISB_STATE_DIR/sessions" -name 'SKILL.md' | grep -q .; then + _bad "nothing from the tree lands in the session directory" +else + _ok "nothing from the tree lands in the session directory" +fi +assert_contains "agents=$FIX" "the policy reports the source" +assert_contains "agents_dest=/home/sandbox/.agents" "and the destination" + +t_name "the same for every harness, cwd mount or not" +sb --dry-run --net none --harness codex; assert_contains "$MOUNT_RE" "codex" +sb --dry-run --net none --shell; assert_contains "$MOUNT_RE" "a shell" +sb --dry-run --net none -- make test; assert_contains "$MOUNT_RE" "a command after --" +sb --dry-run --net none --no-cwd; assert_contains "$MOUNT_RE" "--no-cwd does not take it away" +sb --dry-run --net none --ephemeral-home +assert_contains "$MOUNT_RE" "an ephemeral (tmpfs) home still gets it" +assert_contains "/home/sandbox:rw,nosuid,nodev" "--ephemeral-home is still a tmpfs home" + +t_name "--no-agents does not share it" +sb --dry-run --net none --no-agents +assert_absent "$FIX:/home/sandbox/.agents" "--no-agents" +sb_env AISB_AGENTS=0 --dry-run --net none +assert_absent "$FIX:/home/sandbox/.agents" "AISB_AGENTS=0" +printf 'agents = 0\n' > "$WORK/cfg/config" +sb --print-policy --net none +assert_absent "$FIX" "agents = 0 in the config file" +assert_contains "agents=off" "and the policy says off" +sb --dry-run --net none --agents +assert_contains "$MOUNT_RE" "the CLI wins over the config file" +rm -f "$WORK/cfg/config" + +t_name "AISB_AGENTS_DEST and agents_dest move the mount point" +sb_env AISB_AGENTS_DEST=/srv/agents --dry-run --net none +assert_contains "$FIX:/srv/agents:ro" "environment" +printf 'agents_dest = /opt/dot-agents\n' > "$WORK/cfg/config" +sb --dry-run --net none +assert_contains "$FIX:/opt/dot-agents:ro" "config file" +rm -f "$WORK/cfg/config" + +t_name "an explicit --mount on the same destination wins" +mkdir -p "$WORK/hand-mounted" +sb --dry-run --net none --mount "$WORK/hand-mounted:/home/sandbox/.agents:ro" +assert_contains "$WORK/hand-mounted:/home/sandbox/.agents" "the hand-written mount is kept" +assert_absent "$FIX:/home/sandbox/.agents" "and the automatic one steps aside (podman refuses duplicates)" +assert_contains "already a --mount destination" "and it says why" + +t_name "--agents-dir chooses the source, and a bad one is an error" +mkdir -p "$WORK/other-agents/skills/second" +printf 'name: second\n' > "$WORK/other-agents/skills/second/SKILL.md" +sb --dry-run --net none --agents-dir "$WORK/other-agents" +assert_contains "$WORK/other-agents:/home/sandbox/.agents:ro" "--agents-dir" +assert_absent "$FIX:/home/sandbox/.agents" "replacing the default rather than adding to it" +sb_env AISB_AGENTS_DIR="$WORK/other-agents" --dry-run --net none +assert_contains "$WORK/other-agents:/home/sandbox/.agents:ro" "AISB_AGENTS_DIR" +sb --dry-run --net none --agents-dir "$WORK/agents" --agents-dir "$WORK/other-agents" +assert_contains "$WORK/other-agents:/home/sandbox/.agents" "the last one wins" +assert_count 1 "$WORK/other-agents:/home/sandbox/.agents" "mounted once, not twice" +sb --dry-run --net none --agents-dir "$WORK/no-such-dir" +assert_rc 1 "an explicit --agents-dir that does not resolve is refused" +assert_contains "--agents-dir is not a readable directory" "with a reason" + +t_name "a leading ~ in a path means \$HOME" +# shellcheck disable=SC2088 # quoted on purpose: the script expands ~, not the shell +sb --dry-run --net none --agents-dir '~/no-such-dir-41d5' +assert_contains "$HOME/no-such-dir-41d5" "expanded in a CLI path (see the error)" +assert_rc 1 "and still refused, because it is not a directory" +printf 'agents_dir = ~/no-such-dir-0e2b\n' > "$WORK/cfg/config" +# AISB_AGENTS_DIR would outrank the config file, so this is the one case that needs it blank +sb_env AISB_AGENTS_DIR= --dry-run --net none --agents +assert_contains "$HOME/no-such-dir-0e2b" "expanded in a config-file path too" +rm -f "$WORK/cfg/config" + +t_name "a missing default directory is a skip, not a failure" +sb_env AISB_AGENTS_DIR="$WORK/nope" --dry-run --net none +assert_rc 0 "no directory, no mount, no complaint" +assert_absent "/home/sandbox/.agents" "nothing is mounted there" +sb_env AISB_AGENTS_DIR="$WORK/nope" --print-policy --net none +assert_contains "agents=off" "the policy reports it off" +sb_env AISB_AGENTS_DIR="$WORK/nope" --dry-run --net none --agents +assert_rc 0 "--agents with nothing to share is a warning, not a refusal" +assert_contains "no readable directory" "and it says so" + +t_name "an unreadable directory is treated as absent" +NOREAD="$WORK/mode-0700"; mkdir -p "$NOREAD/skills/x" && chmod 000 "$NOREAD" +sb_env AISB_AGENTS_DIR="$NOREAD" --dry-run --net none +assert_rc 0 "not readable, so not shared" +assert_absent "$NOREAD:/home/sandbox/.agents" "and not mounted" +chmod 755 "$NOREAD" + +t_name "a symlinked ~/.agents is followed" +LINK="$WORK/agents-link"; ln -sfn "$FIX" "$LINK" +sb_env AISB_AGENTS_DIR="$LINK" --dry-run --net none +assert_contains "$LINK:/home/sandbox/.agents:ro" "mounted under the name you configured" + +t_name "the mount never widens the sandbox in any other way" +sb --dry-run --net none +LINE="$(inv)" +assert_absent "--privileged" "still unprivileged" +assert_contains "--cap-drop=ALL" "still capability-free" +assert_contains "--security-opt no-new-privileges" "still no new privileges" +[[ "$(printf '%s' "$LINE" | grep -o "$FIX" | wc -l | tr -d ' ')" == "1" ]] \ + && _ok "the host directory appears exactly once" || _bad "the host directory appears exactly once"