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
152 lines
7.7 KiB
Bash
152 lines
7.7 KiB
Bash
#!/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"
|