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
+47
View File
@@ -102,6 +102,53 @@ assert_contains "cached" "the home cache volume persists across runs"
sb $DARK --ephemeral-home -- bash -c 'test -f "$HOME/cached-marker" && echo LEAK || echo isolated'
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"