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
+7 -1
View File
@@ -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 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: 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 capabilities inside the container, resource caps, and egress limited to an
allowlist that the sandboxed process cannot lift. 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 ## Non-negotiables
1. **Single script.** The Containerfile, the container entrypoint, the squid 1. **Single script.** The Containerfile, the container entrypoint, the squid
+17 -1
View File
@@ -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` 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. 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) ## Follow-ups (not yet done)
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container * **`--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 + * **Quarantine mode for untrusted repos**: `--net none` + read-only mount +
`--ephemeral-home` as a single flag, plus a warning if `.git` hooks exist. `--ephemeral-home` as a single flag, plus a warning if `.git` hooks exist.
* **`--record`**: optional pcap/CONNECT-log capture of a session for review. * **`--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. * Packaging: a `quadlet`/systemd unit for a long-lived named gate.
* Signature verification for the base where the registry signs it (cosign/sigstore), * 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 so a pin is authenticated rather than merely immutable; and an optional local
+28 -2
View File
@@ -101,8 +101,9 @@ Asking for a variable that is not set fails the run closed.
## Filesystem ## Filesystem
Your cwd is mounted at `/project` and is the working directory — that is the whole Your cwd is mounted at `/project` and is the working directory. That is the only
default. Everything else is opt-in: *writable* mount, and nothing else on the host is visible except the one exception
below. Everything else is opt-in:
```bash ```bash
--mount ~/.ssh:ro # if the agent must be able to push --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 Files the agent writes land on your disk owned by **your** uid, not root
(`--userns=keep-id`, verified by `doctor`). (`--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 ## Pi inside the sandbox
The image has `pi` baked in, and its home is a volume, so out of the box the sandboxed The image has `pi` baked in, and its home is a volume, so out of the box the sandboxed
+123 -10
View File
@@ -3,7 +3,8 @@
# ai-sandbox -- rootless podman sandbox for autonomous agentic development. # ai-sandbox -- rootless podman sandbox for autonomous agentic development.
# #
# One script. It builds a small Ubuntu 24.04 image, then runs any command inside # 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 # no root and no capabilities inside the container, hard resource caps, and
# outbound network access limited to an explicit allowlist that the in-container # outbound network access limited to an explicit allowlist that the in-container
# process cannot lift. # 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_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_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_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_UID="${AISB_UID:-1000}"
AISB_GID="${AISB_GID:-1000}" AISB_GID="${AISB_GID:-1000}"
# SELinux relabel mode: ':z' (shared label) is what an SELinux host needs; hosts # 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)"; } 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"; } 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_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_v4cidr() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; }
is_v6() { [[ "$1" == *:* ]]; } is_v6() { [[ "$1" == *:* ]]; }
@@ -117,6 +124,18 @@ join() {
printf '%s' "$out" 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> dedup() { # dedup <name-of-array>
local -n _dd_in="$1" local -n _dd_in="$1"
local v out=() seen=" " local v out=() seen=" "
@@ -169,13 +188,24 @@ NETWORK
FILESYSTEM FILESYSTEM
The current directory is bind-mounted at /project and is the working directory. 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>. --mount PATH[:DEST][:ro] Extra bind mount. Repeatable. Default rw, DEST=/mnt/<name>.
--no-cwd Do not mount the current directory. --no-cwd Do not mount the current directory.
--workdir DIR Working directory in the container. Default /project. --workdir DIR Working directory in the container. Default /project.
--home-cache Persist /home/sandbox in a named volume (default). --home-cache Persist /home/sandbox in a named volume (default).
--ephemeral-home Discard /home/sandbox on exit. --ephemeral-home Discard /home/sandbox on exit.
--read-only Read-only root filesystem (plus tmpfs /tmp). --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 ENVIRONMENT
Inherited by default: TERM LANG LC_ALL LC_CTYPE TZ. Nothing else. 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} \$HOME/.config/ai-sandbox/{config,allow} and ./.ai-sandbox/{config,allow}
Every default can also be set as an environment variable: 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_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), 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 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), (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 case "$key" in
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\ 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|\ 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" ;; *) warn "$file: unknown key: $key" ;;
esac esac
done < "$file" 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_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_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_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="" 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=() declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=()
COMMAND=() COMMAND=()
@@ -329,7 +361,8 @@ COMMAND=()
strict_on() { [[ "${OPT_STRICT:-0}" == 1 || "${AISB_STRICT_ARGS:-0}" == 1 ]]; } strict_on() { [[ "${OPT_STRICT:-0}" == 1 || "${AISB_STRICT_ARGS:-0}" == 1 ]]; }
KNOWN_OPTS=( 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 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 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 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) need "$1" "${2:-}"; OPT_mount+=("$2"); shift 2 ;;
--mount=*) OPT_mount+=("${1#*=}"); shift ;; --mount=*) OPT_mount+=("${1#*=}"); shift ;;
--no-cwd) OPT_NO_CWD=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) need "$1" "${2:-}"; OPT_WORKDIR="$2"; shift 2 ;;
--workdir=*) OPT_WORKDIR="${1#*=}"; shift ;; --workdir=*) OPT_WORKDIR="${1#*=}"; shift ;;
--home-cache) OPT_HOME_CACHE=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}"; } 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() { resolve_opts() {
POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}" POL[net]="${OPT_NET:-${AISB_NET:-$(cfgv net allow)}}"
POL[harness]="${OPT_HARNESS:-${AISB_HARNESS:-$(cfgv harness pi)}}" 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[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)}}" 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) ------------------------- # --- 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)}}" 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 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)}}" 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[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}"
POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}" POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}"
POL[home_cache]="${OPT_HOME_CACHE:-${AISB_HOME_CACHE:-$(cfgv home_cache 1)}}" 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_addr]="${AISB_GATE_ADDR:-$(cfgv gate_addr 100.64.0.1)}"
POL[gate_port]="${AISB_GATE_PORT:-$(cfgv gate_port 18081)}" POL[gate_port]="${AISB_GATE_PORT:-$(cfgv gate_port 18081)}"
POL[extra_args]="${AISB_EXTRA_ARGS:-$(cfgv extra_args '')}" POL[extra_args]="${AISB_EXTRA_ARGS:-$(cfgv extra_args '')}"
@@ -502,6 +580,17 @@ validate_opts() {
if [[ "${POL[net]}" == full ]]; then if [[ "${POL[net]}" == full ]]; then
warn "--net full: the container has unrestricted internet access" warn "--net full: the container has unrestricted internet access"
fi 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 if [[ "${POL[net]}" == allow && ${#P_allow[@]} -eq 0 && -z "${POL[proxy]}" ]]; then
die "no egress allowlist configured. Refusing to start wide open. die "no egress allowlist configured. Refusing to start wide open.
add one: $PROG --allow aipi.reeseapps.com --env AI_API_TOKEN add one: $PROG --allow aipi.reeseapps.com --env AI_API_TOKEN
@@ -1527,13 +1616,24 @@ build_argv() {
fi fi
[[ "${POL[readonly]}" == 1 ]] && ARGV+=(--read-only) [[ "${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 local m src dst opt rest
for m in ${P_mount[@]+"${P_mount[@]}"}; do for m in ${P_mount[@]+"${P_mount[@]}"}; do
src="${m%%:*}"; rest="${m#*:}"; dst=""; opt="" src="${m%%:*}"; rest="${m#*:}"; opt=""
if [[ "$rest" == *:* ]]; then dst="${rest%%:*}"; opt="${rest##*:}"; if [[ "$rest" == *:* ]]; then opt="${rest##*:}"; fi
elif [[ -n "$rest" && "$rest" != "$m" ]]; then dst="$rest"; fi dst="$(mount_dest "$m")"
[[ -e "$src" ]] || die "--mount: path does not exist: $src" [[ -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')" [[ -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}") if [[ -n "$opt" ]]; then ARGV+=(-v "${src}:${dst}:${opt}${SELOPT_SEP}")
else ARGV+=(-v "${src}:${dst}${SELOPT}"); fi 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_names=%s\n' "$(join ' ' ${ENV_NAMES[@]+"${ENV_NAMES[@]}"})"
printf 'env_missing=%s\n' "$(join ' ' ${MISSING_ENV[@]+"${MISSING_ENV[@]}"})" printf 'env_missing=%s\n' "$(join ' ' ${MISSING_ENV[@]+"${MISSING_ENV[@]}"})"
printf 'mounts=%s\n' "$(join ';' ${P_mount[@]+"${P_mount[@]}"})" 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)" 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
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 if ((${#P_allow[@]})); then
chk INFO "allowlist from config/env: ${P_allow[*]}" chk INFO "allowlist from config/env: ${P_allow[*]}"
else else
+16
View File
@@ -65,6 +65,22 @@ net = allow
# Where to read the host config from (default ~/.pi/agent). # Where to read the host config from (default ~/.pi/agent).
# pi_agent_dir = ~/.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 ------------------------------------------------------------- # --- behaviour -------------------------------------------------------------
# SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable. # SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable.
# Defaults to ':z' when getenforce exists. # Defaults to ':z' when getenforce exists.
+29 -2
View File
@@ -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 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. 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 ## Per harness
```bash ```bash
@@ -126,8 +153,8 @@ pi --allow aipi.reeseapps.com --mount ../docs:/docs:ro
# nothing persistent, nothing readable, nothing writable outside the repo # nothing persistent, nothing readable, nothing writable outside the repo
pi --net none --ephemeral-home --read-only -- make build pi --net none --ephemeral-home --read-only -- make build
# an entirely blank sandbox for untrusted code # an entirely blank sandbox for untrusted code: no project, no home, no skills
pi --net none --no-cwd --ephemeral-home --read-only -- bash 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 # 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 pi --strict-args --net allow --allow-file .ai-sandbox/allow --env-file .ci/env -- npm ci
+14 -2
View File
@@ -19,7 +19,8 @@ registry you don't control, or someone with access to your host user account.
| Attack | Stopped by | Evidence | | 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` | | 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` | | 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` | | 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 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 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. "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 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 `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 uses is your real one. So the bridged config is a convenience, not a narrower
+47
View File
@@ -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' 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" 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" 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' 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" assert_contains "rootfs_locked=ok" "--read-only blocks writes outside mounts"
+5
View File
@@ -75,9 +75,14 @@ for f in "${FILES[@]}"; do
# Never bridge the developer's own ~/.pi/agent into a test: tests that exercise the # 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. # 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}" 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 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. # 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_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE
unset AISB_AGENTS AISB_AGENTS_DEST
source "$f" source "$f"
summary summary
) || FAILED_FILES+=("$name") ) || FAILED_FILES+=("$name")
+151
View File
@@ -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"