Skip to content

claude-code feature mounts ~/.claude read-write, but its README documents read-only mounts for prompt-injection safety #108

Closed

Description

@blooop

The claude-code devcontainer feature's documented security property is not
implemented.

Found incidentally while resolving a networking question on
#93's map
(#97) — it is unrelated to that
map's destination, so it is filed here rather than tracked there.

.devcontainer/claude-code/README.md:20-40 documents granular per-file mounts,
with CLAUDE.md, agents/, commands/ and hooks/ mounted read-only, and
gives an explicit prompt-injection argument for why they are read-only.

.devcontainer/claude-code/devcontainer-feature.json instead mounts the whole of
~/.claude read-write:

"mounts": [
    "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind"
]

So the container can write the host's hooks/, commands/ and CLAUDE.md — the
exact files the README says are read-only because they are executable
instructions that survive into later sessions. The mitigation is documented but
absent.

Worth deciding which of the two is the intention: tighten the mounts to match the
README, or correct the README to stop claiming a protection that is not there.
The first is what the README's own reasoning argues for.

Not verified: whether the granular mount list ever existed and regressed, or was
only ever aspirational. git log on the feature would say.

Activity

  1. self-assigned this
    on Aug 15, 2026
  2. blooop commented on Aug 15, 2026

    @blooop
    OwnerAuthor

    breadcrumb: direction settled, and it is the first of the two the ticket
    offers: tighten the mounts to match the README, not the other way round. The
    README's own argument decides it — the files it calls read-only are read-only
    because they are executable instructions that survive into later sessions, so
    deleting the claim would keep the exposure and lose the reason. Decided by the
    orchestrator under standing autonomous-work authorisation, recorded here as the
    contract this build is held to.

    The invariant the build owes: the documented mounts and the shipped mounts
    agree, and every executable-instruction path is read-only.
    If a path turns out
    to need writing at runtime, the faithful variant is a narrower writable set with
    the instruction files still read-only, and the README restated to say exactly
    what ships — never a README that promises a protection the manifest does not
    carry.

    One thing the ticket left unverified is now verified: the granular mount list
    never existed. The feature manifest has had exactly one revision since the
    initial commit, and it mounts the whole config directory read-write. The
    protection was aspirational from the first day rather than a regression.

    Seams this will be tested at, in order of how much they can be trusted:

    1. The feature manifest's mount list, read as data. Every path the README
      calls protected is mounted read-only; the whole-directory read-write mount is
      gone; the writable set is exactly the two state files the README names as
      necessary. Stated as rules over the list, so a sixth mount cannot arrive
      writable without failing.
    2. The README and the manifest, parsed from both sides. The defect this
      ticket reports is precisely that those two drifted, so the test that matters
      is the one that makes them move together — a documented path with no mount,
      or a mount with no documentation, is a failure.
    3. The host-side pre-create hook. A bind mount whose source is missing is
      not a degraded container, it is no container; the same hook that already
      creates the ssh file this repo mounts is where every newly mounted config
      path gets created too, idempotently, since it has to survive running inside
      the container it built.
    4. A real container. The first three are all statements about text. Whether
      a devcontainer feature's mount entries reach docker as read-only binds is a
      question only a running container answers, so one gets built from this
      feature and the protection is checked by trying to write through it.
  3. blooop commented on Aug 15, 2026

    @blooop
    OwnerAuthor

    breadcrumb: the mechanism is measured rather than inferred, and one of the
    measurements changed what shipped.

    A Feature's readonly really does reach Docker. This was worth checking:
    the published Feature schema has no readonly at all — a mount there is an
    object of source, target and type, additionalProperties: false, and the string
    form this feature has always used is not in the schema either — while every
    implementation accepts it. Built against a throwaway host directory, the
    container's own /proc/mounts reports ro for all five protected paths and rw
    for the two state files, and a write to each protected path is refused with
    Read-only file system — as root. The host's files are unchanged afterwards, so
    the refusal is a refusal and not a write into a copy.

    Nothing Claude needs to write is read-only. The question the ticket flagged —
    sessions, todos, history, projects all live under the same directory — resolves
    the easy way: those paths are simply not mounted, so they are the container's own
    and writable. The mount list is an allow-list rather than a directory with holes
    in it, which is also why a directory Claude starts writing to next month cannot
    quietly become another way onto the host.

    The cost is real and is now written down. A container no longer resumes a
    session started on the host, and the host's skills and plugins are not visible in
    there. Mounting those read-only is the same argument and would be a reasonable
    follow-up; it is deliberately not in this change.

    The missing-path worry turned out not to be a new one. A bind whose source is
    missing is refused before any container exists — nothing is created on the host,
    so the feared root-owned directory named CLAUDE.md does not happen. And the
    whole-directory mount that preceded this failed in exactly the same way on a host
    with no configuration directory, measured both ways. The list the pre-create hook
    has to create is longer; the failure mode is not new.

    One thing the mounts made newly load-bearing. The pre-create hook runs inside
    containers built this way, where the paths it would create are the read-only
    mounts — so every line of it is guarded on absence, and that is now checked in a
    real container instead of by proxy.

  4. blooop commented on Aug 15, 2026

    @blooop
    OwnerAuthor

    breadcrumb: built and open as PR #219 — the feature now mounts the paths its
    README documents, with the executable-instruction files read-only, and everything
    else under the configuration directory is the container's own.

    Held by seven unit tests that require the README and the manifest to agree in
    both directions rather than keeping a third copy of the list, and one e2e test
    that proves the protection in a real container in about twenty seconds — built
    against a scratch host directory, so it can attempt the writes for real without
    any run of it, passing or failing, being able to touch a developer's
    configuration. Both suites were mutation-checked (thirteen mutations: flag
    dropped, mount removed, target skewed, whole-directory mount restored, a README
    bullet deleted, the hook writing unconditionally, and more) and every one is
    caught by the test that should catch it.

    Every check on the PR is green, including the e2e job — so the protection is
    confirmed on a second machine that is not the one it was developed on.

  5. blooop commented on Aug 15, 2026

    @blooop
    OwnerAuthor

    wf-review verdict on #219: Approve — both axes clean; the documented read-only protection now genuinely ships (manifest↔README cross-checked line by line, mutations reproduced, e2e verified in a real container by builder + CI + reviewer independently). Doc residue both axes flagged (install.sh's stale banner, incomplete publishing recipe) closed during review as 45cecab. Merging once checks re-green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions