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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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