24. Ship agent skills by read-only bind, not into the user’s config#
Date: 2026-09-15
Status#
Accepted
Amended 2026-10-08: opt-in Claude Code plugins under plugins/, offered
through a plugin marketplace in this repository that the installer makes
known in the managed settings. The skill bind below stays the mechanism for
shipped skills; see the amendment at the end for why the marketplace’s
costs, rejected here for shipped skills, are accepted for plugins.
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.
Amendment (2026-10-08): opt-in plugins from a managed marketplace#
Some things a user may want are not skills: the orchestrate plugin
carries hooks and a mod, which a skill bind cannot deliver. They are also
ways of working a user chooses, not part of the isolation, so they should
not load for everyone.
These live as Claude Code plugins under plugins/<name>/, listed in a
marketplace at .claude-plugin/marketplace.json (name claude-sandbox).
The installer’s managed-settings step adds that marketplace to
extraKnownMarketplaces in /etc/claude-code/managed-settings.json, so
Claude registers it at session start and the user only runs
/plugin install <name>@claude-sandbox. It never sets enabledPlugins:
installing a plugin stays the user’s decision. An administrator’s own
entry of the same name is kept.
The objections in the Context above still hold, and are accepted here because they do not bite on an opt-in plugin:
Installs into the user’s plugin cache. A plugin the user chose lives where every other plugin they install lives. Nothing the isolation relies on is in it; its hooks run inside the jail with the session’s privileges, like hooks in the user’s own settings. A plugin script the user would run outside the jail is the exception: it would be writable from inside the session, so such a script must stay root-owned under
/usr/libexec.Needs the network on first use. Claude Code itself needs it.
Claude only. Hooks and mods are Claude-only anyway.
Rejected: loading plugins for every user with --plugin-dir in the claude
profile. It puts a workflow feature on the launch path, and charges every
session its skill descriptions and hook processes. Also rejected: having
the installer run claude plugin marketplace add. The installer runs as
root and execs nothing found through PATH (ADR 26), and the command writes
the invoking user’s ~/.claude; the managed key gets the same result from
a file the installer already owns.
Shipped skills are unchanged: browser-testing, claude-sandbox-user,
verify-sandbox and vscode-headless stay in skills/ and are bound
read-only.