share the host ~/.agents read-only, so skills come along

A sandboxed harness found none of your skills, prompt templates or
standing AGENTS.md, because those live in ~/.agents and only the project
was mounted. It is now bind-mounted at /home/sandbox/.agents by default
-- bound, never copied -- with --no-agents to opt out and --agents-dir
for a curated set, reported by --print-policy and by doctor.

Read-only is the load-bearing part and has no escape hatch: a skill is an
instruction the model follows later, on the host, so a sandbox that could
write ~/.agents had a persistence path out of itself, planted as a
helpful-looking SKILL.md. It holds even under --insecure-keep-root, the
mount lands after /home/sandbox itself so it cannot be shadowed by the
home volume or tmpfs, and an explicit --mount on the same destination
wins because podman refuses duplicates. A missing or unreadable directory
is a silent skip like the pi bridge's; an explicit --agents-dir that
resolves to nothing is a hard error.

The exposure is stated in docs/threat-model.md: the tree, its .git history
and anything pasted into a skill are readable by whatever runs in there.

tests/unit/09_agents.sh covers resolution, precedence and the
bind-never-copy canary; tests/e2e/12_sandbox.sh mounts it in a real
container, checks the host tree survives a run byte for byte and that
even root inside the sandbox cannot write it.

LLM-Generated: true
This commit is contained in:
2026-10-27 20:30:53 -04:00
parent 8ee535f6ae
commit ed2eb462d6
10 changed files with 437 additions and 18 deletions
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# tests/unit/09_agents.sh -- sharing the host ~/.agents (skills, prompts, AGENTS.md).
#
# The bridge binds and never copies, so the two things worth pinning down are that the
# mount is there when it should be, and that it is read-only: a harness that could edit
# your skills could edit the instructions you follow tomorrow on the host. Everything is
# preview, so no container is started -- the real mount is checked in e2e/12_sandbox.sh.
source "$TESTS/lib.sh"
FIX="$WORK/agents"
mkdir -p "$FIX/skills/demo-skill" "$FIX/prompts"
CANARY_SKILL='SKILLCANARY-not-a-file-ai-sandbox-writes'
printf 'name: demo-skill\n%s\n' "$CANARY_SKILL" > "$FIX/skills/demo-skill/SKILL.md"
printf '# global instructions\n' > "$FIX/AGENTS.md"
export AISB_AGENTS_DIR="$FIX"
unset AISB_AGENTS AISB_AGENTS_DEST
PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1
MOUNT_RE="$FIX:/home/sandbox/.agents:ro"
# The last line of --dry-run is the podman invocation; the order of the -v/--mount
# arguments inside it is what decides whether the mount survives being nested in the
# home volume, so it is part of what these tests check.
inv() { printf '%s' "$OUT" | tail -1; }
has_mount() { inv | grep -qF -- "$MOUNT_RE"; }
t_name "shared by default, read-only, where a harness looks for user skills"
sb --dry-run --net none
assert_rc 0 "dry run"
assert_contains "$FIX:/home/sandbox/.agents" "the host directory is mounted at \$HOME/.agents"
assert_contains "$MOUNT_RE" "read-only"
assert_absent "$FIX:/home/sandbox/.agents:rw" "and there is no rw form of it"
if has_mount; then
after="$(inv | sed "s|.*type=volume,source=[^ ]*target=/home/sandbox||")"
[[ "$after" == *"$MOUNT_RE"* ]] && _ok "mounted after /home/sandbox itself, so it is not shadowed" \
|| _bad "mounted after /home/sandbox itself, so it is not shadowed"
else
_bad "mounted after /home/sandbox itself, so it is not shadowed" "no agents mount at all"
fi
assert_absent "$FIX/skills" "the directory itself is mounted, not a cherry-picked skills/"
assert_absent "$FIX/AGENTS.md:" "nor an AGENTS.md singled out"
t_name "it is a bind mount, never a copy"
sb --keep --print-policy --net none
if grep -rlF "$CANARY_SKILL" "$AISB_STATE_DIR" "$AISB_CACHE_DIR" > /dev/null 2>&1; then
_bad "no skill content is copied into ai-sandbox state" \
"found the canary under $AISB_STATE_DIR or $AISB_CACHE_DIR"
else
_ok "no skill content is copied into ai-sandbox state"
fi
if find "$AISB_STATE_DIR/sessions" -name 'SKILL.md' | grep -q .; then
_bad "nothing from the tree lands in the session directory"
else
_ok "nothing from the tree lands in the session directory"
fi
assert_contains "agents=$FIX" "the policy reports the source"
assert_contains "agents_dest=/home/sandbox/.agents" "and the destination"
t_name "the same for every harness, cwd mount or not"
sb --dry-run --net none --harness codex; assert_contains "$MOUNT_RE" "codex"
sb --dry-run --net none --shell; assert_contains "$MOUNT_RE" "a shell"
sb --dry-run --net none -- make test; assert_contains "$MOUNT_RE" "a command after --"
sb --dry-run --net none --no-cwd; assert_contains "$MOUNT_RE" "--no-cwd does not take it away"
sb --dry-run --net none --ephemeral-home
assert_contains "$MOUNT_RE" "an ephemeral (tmpfs) home still gets it"
assert_contains "/home/sandbox:rw,nosuid,nodev" "--ephemeral-home is still a tmpfs home"
t_name "--no-agents does not share it"
sb --dry-run --net none --no-agents
assert_absent "$FIX:/home/sandbox/.agents" "--no-agents"
sb_env AISB_AGENTS=0 --dry-run --net none
assert_absent "$FIX:/home/sandbox/.agents" "AISB_AGENTS=0"
printf 'agents = 0\n' > "$WORK/cfg/config"
sb --print-policy --net none
assert_absent "$FIX" "agents = 0 in the config file"
assert_contains "agents=off" "and the policy says off"
sb --dry-run --net none --agents
assert_contains "$MOUNT_RE" "the CLI wins over the config file"
rm -f "$WORK/cfg/config"
t_name "AISB_AGENTS_DEST and agents_dest move the mount point"
sb_env AISB_AGENTS_DEST=/srv/agents --dry-run --net none
assert_contains "$FIX:/srv/agents:ro" "environment"
printf 'agents_dest = /opt/dot-agents\n' > "$WORK/cfg/config"
sb --dry-run --net none
assert_contains "$FIX:/opt/dot-agents:ro" "config file"
rm -f "$WORK/cfg/config"
t_name "an explicit --mount on the same destination wins"
mkdir -p "$WORK/hand-mounted"
sb --dry-run --net none --mount "$WORK/hand-mounted:/home/sandbox/.agents:ro"
assert_contains "$WORK/hand-mounted:/home/sandbox/.agents" "the hand-written mount is kept"
assert_absent "$FIX:/home/sandbox/.agents" "and the automatic one steps aside (podman refuses duplicates)"
assert_contains "already a --mount destination" "and it says why"
t_name "--agents-dir chooses the source, and a bad one is an error"
mkdir -p "$WORK/other-agents/skills/second"
printf 'name: second\n' > "$WORK/other-agents/skills/second/SKILL.md"
sb --dry-run --net none --agents-dir "$WORK/other-agents"
assert_contains "$WORK/other-agents:/home/sandbox/.agents:ro" "--agents-dir"
assert_absent "$FIX:/home/sandbox/.agents" "replacing the default rather than adding to it"
sb_env AISB_AGENTS_DIR="$WORK/other-agents" --dry-run --net none
assert_contains "$WORK/other-agents:/home/sandbox/.agents:ro" "AISB_AGENTS_DIR"
sb --dry-run --net none --agents-dir "$WORK/agents" --agents-dir "$WORK/other-agents"
assert_contains "$WORK/other-agents:/home/sandbox/.agents" "the last one wins"
assert_count 1 "$WORK/other-agents:/home/sandbox/.agents" "mounted once, not twice"
sb --dry-run --net none --agents-dir "$WORK/no-such-dir"
assert_rc 1 "an explicit --agents-dir that does not resolve is refused"
assert_contains "--agents-dir is not a readable directory" "with a reason"
t_name "a leading ~ in a path means \$HOME"
# shellcheck disable=SC2088 # quoted on purpose: the script expands ~, not the shell
sb --dry-run --net none --agents-dir '~/no-such-dir-41d5'
assert_contains "$HOME/no-such-dir-41d5" "expanded in a CLI path (see the error)"
assert_rc 1 "and still refused, because it is not a directory"
printf 'agents_dir = ~/no-such-dir-0e2b\n' > "$WORK/cfg/config"
# AISB_AGENTS_DIR would outrank the config file, so this is the one case that needs it blank
sb_env AISB_AGENTS_DIR= --dry-run --net none --agents
assert_contains "$HOME/no-such-dir-0e2b" "expanded in a config-file path too"
rm -f "$WORK/cfg/config"
t_name "a missing default directory is a skip, not a failure"
sb_env AISB_AGENTS_DIR="$WORK/nope" --dry-run --net none
assert_rc 0 "no directory, no mount, no complaint"
assert_absent "/home/sandbox/.agents" "nothing is mounted there"
sb_env AISB_AGENTS_DIR="$WORK/nope" --print-policy --net none
assert_contains "agents=off" "the policy reports it off"
sb_env AISB_AGENTS_DIR="$WORK/nope" --dry-run --net none --agents
assert_rc 0 "--agents with nothing to share is a warning, not a refusal"
assert_contains "no readable directory" "and it says so"
t_name "an unreadable directory is treated as absent"
NOREAD="$WORK/mode-0700"; mkdir -p "$NOREAD/skills/x" && chmod 000 "$NOREAD"
sb_env AISB_AGENTS_DIR="$NOREAD" --dry-run --net none
assert_rc 0 "not readable, so not shared"
assert_absent "$NOREAD:/home/sandbox/.agents" "and not mounted"
chmod 755 "$NOREAD"
t_name "a symlinked ~/.agents is followed"
LINK="$WORK/agents-link"; ln -sfn "$FIX" "$LINK"
sb_env AISB_AGENTS_DIR="$LINK" --dry-run --net none
assert_contains "$LINK:/home/sandbox/.agents:ro" "mounted under the name you configured"
t_name "the mount never widens the sandbox in any other way"
sb --dry-run --net none
LINE="$(inv)"
assert_absent "--privileged" "still unprivileged"
assert_contains "--cap-drop=ALL" "still capability-free"
assert_contains "--security-opt no-new-privileges" "still no new privileges"
[[ "$(printf '%s' "$LINE" | grep -o "$FIX" | wc -l | tr -d ' ')" == "1" ]] \
&& _ok "the host directory appears exactly once" || _bad "the host directory appears exactly once"