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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+29
-2
@@ -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
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user