24. Ship agent skills by read-only bind, not into the user’s config#
Date: 2026-09-15
Status#
Accepted
Rests on ADR 13’s placement rule (trust
anchors under /etc and /usr/libexec, read-only inside the session) and
on the XDG home rebinds of ADR 11.
Context#
The vscode-headless skill (PR #46) lets a sandboxed agent run and drive VS
Code on a virtual display. It only reached the agent when the agent’s
workspace was this repository, because skills were discovered from the
repo’s own .claude/skills/. Users of the wheel, the image, or a
clone-and-install into another devcontainer never saw it.
Claude Code discovers skills from ~/.claude/skills/<name>/SKILL.md and
from the workspace’s .claude/skills/; it has no system-wide skills
directory and no managed-settings key that adds one. Codex and Pi have the
same one-level shape under their own config directories.
Options considered:
Copy skills into the user’s
~/.claude/skillsat install. That directory is the user’s own and is shared across every devcontainer that mounts the same terminal config; an installer writing there contaminates it, drifts from the installed version, and leaves a writable copy of the skill’s scripts inside the session.Publish a plugin and enable it through managed settings. Still installs into the user’s plugin cache, needs the network on first use, and covers Claude Code only.
Pass a plugin directory on the command line. Launcher-only and Claude only; Codex and Pi would need separate mechanisms.
Decision#
Shippable skills live in a top-level skills/ tree, separate from
.claude/skills/ (which stays for developing this repository and never
ships). The installer copies that tree, replacing any previous copy, to
/usr/libexec/claude-sandbox/skills/, root-owned and world-readable. The
shadow binds each skill read-only onto the agent’s own skills directory
inside the jail: ~/.claude/skills/<name> for Claude, ~/.codex/skills/<name>
for Codex, ~/.pi/agent/skills/<name> for Pi. One bind per skill, so the
user’s own skills in the same directory stay visible and the depth matches
what every agent discovers.
The launcher creates the agent’s skills directory on the host before launch when anything ships. That is the one deliberate host write, and it is an empty directory the agent would create itself on first use. bwrap also leaves one empty mount-point directory per shipped skill inside it, because the agent’s config directory is a read-write host bind.
The wheel and the image carry the same skills/ tree through the existing
paths: the wheel’s verbatim force-include and the Dockerfile’s install.sh
function list.
Consequences#
Every consumer (dogfood devcontainer, guest clone-and-install, wheel, image) gets the same skills, at the version that was installed, with no change to the user’s
~/.claudebeyond an emptyskills/directory and one empty directory per shipped skill inside it.A compromised session cannot edit a shipped skill’s scripts: the bind is read-only and the source is outside the sandbox’s writable set.
A user skill with the same name as a shipped one is masked for the session; the launcher warns. An empty directory of that name is the mount point a previous session left, masks nothing and does not warn. Shipped skill names should be distinctive.
Skills are visible to the agent only inside a sandboxed session. Running an agent unwrapped (with the gate hatch) does not see them, which is consistent: the skills are part of the sandbox.
The pattern is harness-agnostic. Adding a fourth agent means adding its skills path to its profile, nothing else.
Repo-development skills remain opt-in: a user who wants one copies it into their own
~/.claude/skills.