Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 63 additions & 19 deletions .devcontainer/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ The following files and directories from your **host machine** are mounted into
- `~/.claude/hooks/` → Event-driven shell hooks
- `~/.claude/skills/` → Agent Skills, and the links a skill installer leaves there
- `~/.claude/wf-skills/` → the skill bodies those links point at
- `~/.claude/shared-skills/` → Skill bodies shared by Claude and Codex
- `~/.agents/skills/` → Codex skill discovery and shared skill bodies

These are **read-only** (`readonly` flag) to prevent:
- Prompt injection attacks that could modify your Claude configuration
Expand All @@ -36,15 +38,51 @@ coincidence — see "Why only directories are mounted" below. `CLAUDE.md` and
`settings.json` are the two instruction files this list does not cover, because
a read-only mount over either of them could not be kept.

Skills are two mounts because they are one thing. An installer that keeps the
prompt bodies in a sibling directory and leaves *relative* links behind —
`skills/wf -> ../wf-skills/wf`, which is what `wf skills install` from
blooop/wayfinder writes — needs both sides in here or every link arrives
dangling, and a container with dangling links has no skills at all rather than
stale ones. Read-only for the same reason `commands/` is: a skill is executable
### Shared skills for Claude and Codex

The feature mounts both discovery directories at the same paths relative to the
container home. The host username can differ from the container's `vscode` user.
Store a skill once and link it into the other agent's discovery directory:

```text
~/.agents/skills/research/SKILL.md
~/.claude/skills/research -> ../../.agents/skills/research
```

A shared payload inside the Claude configuration also works:

```text
~/.claude/shared-skills/sync/SKILL.md
~/.claude/skills/sync -> ../shared-skills/sync
~/.agents/skills/sync -> ../../.claude/shared-skills/sync
```

Both the links and their target directories are mounted read-only. Protecting
only the discovery directory would still allow writes through a link into the
writable `~/.claude` parent. Existing `skills/wf -> ../wf-skills/wf` links remain
supported by the `wf-skills` mount.

Read-only for the same reason `commands/` is: a skill is executable
instructions. That costs one thing, and it is a warning rather than a failure —
`wf` heals its own links on every launch, so a `wf` run *inside* a container
reports that it could not refresh them and carries on.
reports that it could not refresh them and carries on. Any installer that
rewrites links on startup behaves the same way.

The feature preserves your selection of skills. It creates missing mount roots
but does not add, rewrite, or remove individual links. Keep those in your dotfiles
or skill installer. Relative links to the mounted roots work across usernames;
absolute host paths and targets outside those roots need their own container
configuration. A pre-existing skill or symlink is never replaced by the host hook.

Codex installation, authentication and `~/.codex/config.toml` remain the
workspace's responsibility. This feature shares skills and does not mount the
host's Codex credentials or session state. Recreate existing containers to pick
up the new mounts. A plain `dl` launch of a repo that does not use this feature
still gets its skills from that repo's configuration or dotfiles setup.

Dotfiles installers must skip the mounted `~/.agents/skills` root, just as they
skip host-mounted Claude configuration. Detect ownership from the mount table;
a container profile alone cannot tell a local directory from a host bind.

Comment on lines +84 to 86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): The documentation requires dotfiles installers to detect and skip the host-mounted ~/.agents/skills root, but the diff adds no installer or mount-ownership handling for that path. A dotfiles refresh therefore still treats the read-only host bind as container-owned state and attempts to reconcile it, causing failures on the read-only mount or violating the promise to preserve existing skills and links.

Triggers: When a workspace using this feature runs its dotfiles installer or refreshes dotfiles.

Suggested fix: Update the dotfiles provisioning path to detect the ~/.agents/skills mount in /proc/self/mountinfo and exclude that root, including its descendants, from reconciliation.

### Read-Write Mounts (Authentication & State)
- `~/.claude/.credentials.json` → OAuth access/refresh tokens
Expand Down Expand Up @@ -110,7 +148,7 @@ container sees the host's copy of each, live, and can write to all of them.
This is the part of the layout to weigh before using it. The read-only list is
an allow-list of *protection*, not of visibility: a directory Claude starts
writing to next month is visible and writable from the container the day it
appears, and only the five named directories are proof against a prompt
appears, and only the named directories are proof against a prompt
injection that tries to edit its own instructions — and that only in an
unprivileged container; see "The read-only mounts are not a container-escape
boundary".
Expand Down Expand Up @@ -248,15 +286,17 @@ container is created:
├── commands/ # Custom commands
├── hooks/ # Event hooks
├── skills/ # Agent Skills
├── shared-skills/ # Bodies shared with Codex
└── wf-skills/ # Skill bodies
~/.agents/skills/ # Codex discovery and shared skill bodies
```

**None of them is optional.** A missing one aborts the container create rather
than producing a warning — see "The host-side prerequisite" above, which is how
this is normally handled. By hand, it is:

```bash
mkdir -p ~/.claude/{agents,commands,hooks,skills,wf-skills}
mkdir -p ~/.claude/{agents,commands,hooks,skills,wf-skills,shared-skills} ~/.agents/skills
```

Only directories appear here, and that is the point of the layout rather than an
Expand Down Expand Up @@ -379,7 +419,7 @@ devpod up . --recreate

## Modifying Configuration

The five instruction directories — `agents/`, `commands/`, `hooks/`, `skills/`, `wf-skills/` are read-only, so an ordinary container process cannot add or change an agent, command, hook or skill. (A *privileged* container can remount them; see "The read-only mounts are not a container-escape boundary".) Everything else under `~/.claude` is writable and reaches the host, `settings.json` and `CLAUDE.md` included; see "Why only directories are mounted" for why those two could not be protected.
The instruction directories — `agents/`, `commands/`, `hooks/`, `skills/`, `wf-skills/`, `shared-skills/`, plus `~/.agents/skills/`, are read-only, so an ordinary container process cannot add or change an agent, command, hook or skill. (A *privileged* container can remount them; see "The read-only mounts are not a container-escape boundary".) Everything else under `~/.claude` is writable and reaches the host, `settings.json` and `CLAUDE.md` included; see "Why only directories are mounted" for why those two could not be protected.

To change configuration:

Expand Down Expand Up @@ -552,20 +592,24 @@ prerequisite" — that is what creates them, on every create, for everyone. To
unblock one machine now:

```bash
mkdir -p ~/.claude/{agents,commands,hooks}
touch ~/.claude/CLAUDE.md
echo '{}' > ~/.claude/settings.json
mkdir -p ~/.claude/{agents,commands,hooks,skills,wf-skills,shared-skills} ~/.agents/skills
```

Directories only. `CLAUDE.md` and `settings.json` have no mount of their own and
so cannot be what is missing — creating them here fixed nothing, and the
`echo '{}' >` that used to stand here truncated the settings file of anyone who
already had one.

## Security Notes

This implementation makes conscious security trade-offs to enable OAuth authentication and persistent setup state:

### What's Protected (Read-Only Mounts)

Five directories, and only these five: **`agents/`**, **`commands/`**, **`hooks/`**,
**`skills/`**, **`wf-skills/`**. They carry the code and instructions Claude
executes, which is why they are the ones singled out.
The directories listed under [Read-Only Mounts](#read-only-mounts-security-protected)
carry the code and instructions the agents execute. The list includes discovery
links and the shared directories they point to. The manifest-to-documentation
check in `test/unit/test_claude_code_feature_mounts.py` keeps that list current.

`CLAUDE.md` and `settings.json` are **not** among them. They are files, and a
file cannot be individually protected here — see "Why only directories are
Expand All @@ -589,7 +633,7 @@ bind mount. That includes:

- Only use this feature in **trusted repositories**, and treat the container as
having the same access to your Claude account that you do
- The five read-only directories hold against an ordinary container process
- The read-only directories hold against an ordinary container process

That is the honest list. In particular it is *not* true that "writable files are
limited to authentication/state only", and it is *not* true that "all
Expand All @@ -604,7 +648,7 @@ and **the devcontainer in this repository is privileged**, because the
`docker-in-docker` feature it enables brings `"privileged": true` with it
(`.devcontainer/devcontainer.json:76`).

So for this repo's own container, read the five directories as protection against
So for this repo's own container, read the protected directories as protection against
a prompt injection that tries to edit its own instructions — a mistake, in other
words — and not as protection against code that is actively trying to get out.

Expand All @@ -615,7 +659,7 @@ words — and not as protection against code that is actively trying to get out.
under `projects/`
- A process in the container can write a hook command into the host's
`settings.json`, which is host command execution
- A *privileged* container can additionally remount the five read-only
- A *privileged* container can additionally remount the read-only
directories read-write and rewrite the host's agents, commands, hooks and
skills
- **Recommendation**: Only use in repositories you trust, as you would with any
Expand Down
23 changes: 17 additions & 6 deletions .devcontainer/claude-code/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
├── commands/ # Custom commands (read-only mount)
├── hooks/ # Event hooks (read-only mount)
├── skills/ # Agent Skills (read-only mount)
├── shared-skills/ # Shared skill bodies (read-only mount)
└── wf-skills/ # Skill bodies (read-only mount)
~/.agents/skills/ # Codex skills (read-only mount)
```

Only the directories are mounted. `~/.claude` itself is one read-write bind, and
the five instruction directories are read-only binds on top of it; the files are
the instruction directories are read-only binds on top of it; the files are
reached *through* the directory rather than bound one at a time. That is what
keeps them live — a bind mount of a file does not survive the host replacing it
by rename, which is what Claude does on every token refresh — and it is why
Expand Down Expand Up @@ -166,12 +168,12 @@ reached through it are missing or unwritable.
### Issue 5: "Read-only file system" Error

**Symptoms:**
- Error when trying to write to `~/.claude/CLAUDE.md` or similar
- Error when trying to write to `~/.claude/skills/` or another instruction directory
- Operations fail with "Read-only file system"

**Expected Behavior:**
This is intentional! The instruction directories are mounted read-only:
- `agents/`, `commands/`, `hooks/`, `skills/`, `wf-skills/` → Read-only
- `agents/`, `commands/`, `hooks/`, `skills/`, `wf-skills/`, `shared-skills/` and `~/.agents/skills/` → Read-only

`CLAUDE.md` and `settings.json` are **not** protected, and a write to either
succeeds and reaches the host. See "Why only directories are mounted" in the
Expand Down Expand Up @@ -374,13 +376,22 @@ watch -n 1 'stat ~/.claude/.claude.json | grep Modify'
## Security Considerations

### What's Protected (Read-Only)
- `CLAUDE.md` - Prevents prompt injection
- `settings.json` - Prevents config tampering
- `agents/`, `commands/`, `hooks/` - Prevents malicious modifications

The instruction directories, and only those: see the tree at the top of this
page, which a test holds to the manifest. They carry the code and instructions
the agents execute, which is why they are the ones singled out.

### What's Writable (Necessary Risk)
- `.credentials.json` - OAuth tokens (necessary for auth)
- `.claude.json` - Setup state (necessary to skip wizard)
- `CLAUDE.md` - a prompt injection can edit it, and the edit reaches the host
- `settings.json` - can name a hook command inline, so a write here is host
command execution

The last two were mounted read-only under the old file-mount layout and are
not protected any more. A read-only mount over a file does not survive the
host replacing that file by rename, so the protection ended at the developer's
next edit; see "Why only directories are mounted" in the README.

### Mitigation
- Only use in trusted repositories
Expand Down
8 changes: 5 additions & 3 deletions .devcontainer/claude-code/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "Claude Code CLI",
"id": "claude-code",
"version": "0.5.0",
"description": "Installs Claude Code CLI via pixi and sets up persistent configuration",
"version": "0.6.0",
"description": "Installs Claude Code and shares host agent skills with Claude and Codex",
"options": {},
"documentationURL": "https://github.com/anthropics/devcontainer-features",
"licenseURL": "https://github.com/anthropics/devcontainer-features/blob/main/LICENSE",
Expand All @@ -22,6 +22,8 @@
"source=${localEnv:HOME}/.claude/commands,target=/home/vscode/.claude/commands,type=bind,readonly",
"source=${localEnv:HOME}/.claude/hooks,target=/home/vscode/.claude/hooks,type=bind,readonly",
"source=${localEnv:HOME}/.claude/skills,target=/home/vscode/.claude/skills,type=bind,readonly",
"source=${localEnv:HOME}/.claude/wf-skills,target=/home/vscode/.claude/wf-skills,type=bind,readonly"
"source=${localEnv:HOME}/.claude/wf-skills,target=/home/vscode/.claude/wf-skills,type=bind,readonly",
"source=${localEnv:HOME}/.claude/shared-skills,target=/home/vscode/.claude/shared-skills,type=bind,readonly",
"source=${localEnv:HOME}/.agents/skills,target=/home/vscode/.agents/skills,type=bind,readonly"
]
}
14 changes: 8 additions & 6 deletions .devcontainer/claude-code/init-host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

# The claude-code feature mounts the developer's Claude configuration as the
# directory itself, plus a read-only mount over each subdirectory holding
# *executable instructions* -- agents/, commands/, hooks/, skills/ and
# wf-skills/. Every one of those sources has to exist before the container is
# created, and the cost of a missing one is not a warning: the create is refused
# *executable instructions*, plus Codex's ~/.agents/skills. The mount list and
# these directory prerequisites are checked together by
# test/unit/test_claude_code_feature_mounts.py. Every source has to exist before
# the container is created. A missing one means the create is refused
# outright with `bind mount source path does not exist`, measured on devpod
# 0.26.1 -- and nothing is written to the host when it happens, which is why
# creating them here is the whole fix.
Expand All @@ -27,7 +28,7 @@
# so a token refresh fails
#
# A mount of a *directory* survives the same rename with its flags intact, which
# is why the read-only list is exactly the five instruction directories and why
# is why the read-only list is the instruction directories and why
# CLAUDE.md and settings.json are no longer mounted at all: under a writable
# parent their read-only mounts were enforceable only until the developer next
# edited them, which is worse than not claiming the protection.
Expand Down Expand Up @@ -139,7 +140,7 @@ done
#
# Every line is guarded on absence, and that is load-bearing rather than tidy.
# Run from *inside* a container this repo built -- which is the point of giving
# it a Docker daemon -- the five instruction directories are the read-only
# it a Docker daemon -- the instruction directories are the read-only
# mounts, and a write to one fails with EROFS. A non-zero initializeCommand
# aborts `devpod up` outright. `mkdir -p` on a directory that already exists
# writes nothing and is safe there.
Expand All @@ -152,7 +153,8 @@ done
# source, and on a host that has never run Claude it is indistinguishable from
# a logged-out session.
mkdir -p "$HOME/.claude" "$HOME/.claude/agents" "$HOME/.claude/commands" \
"$HOME/.claude/hooks" "$HOME/.claude/skills" "$HOME/.claude/wf-skills"
"$HOME/.claude/hooks" "$HOME/.claude/skills" "$HOME/.claude/wf-skills" \
"$HOME/.claude/shared-skills" "$HOME/.agents/skills"

# known_hosts is mounted as a *file*, and Docker creates nothing for a file
# source: if it is missing the container does not start degraded, it does not
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- The local container feature shares `~/.agents/skills` with Codex and mounts
`~/.claude/shared-skills` read-only. Relative links between the shared roots
now resolve across host and container usernames, and writes through those
links cannot modify the host's shared skill bodies. Existing containers need
recreation to receive the mounts.

## [0.34.0] - 2026-09-09

### Fixed
Expand Down
16 changes: 16 additions & 0 deletions docs/workspace-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ that is *already running* skips that step, and the token it was given at startup
stays in place, including one it was given before you set
`DEVLAUNCH_NO_GH_TOKEN`. Run `dl <workspace> restart` to replace it.

## Shared agent skills

The repo's [local container feature](../.devcontainer/claude-code/README.md#shared-skills-for-claude-and-codex)
mounts `~/.agents/skills` for Codex alongside Claude's discovery directory.
It also protects `~/.claude/shared-skills`, so a relative link can point into
that directory without making the skill body writable from the container.
Both storage layouts work across different host and container usernames.

This is a feature opt-in, not a mount injected by `dl` into every workspace.
Repos with container-local configuration can provision the same directories
through dotfiles. Existing containers using the feature need recreation to
receive its new mounts. The feature preserves existing skills and links; it
does not install Codex or share its credentials and session state. Dotfiles
installers should skip host-mounted skill roots rather than reconcile their
contents through a read-only bind.

## Claude authentication

`claude` starts in every workspace `dl` opens without asking for a login. The
Expand Down
Loading
Loading