Skip to content

feat: explicit profile-level host mounts, host_mounts (RM-12) - #31

Merged
AviBackToBlack merged 8 commits into
mainfrom
roadmap/RM-12-host-mounts
Aug 19, 2026
Merged

feat: explicit profile-level host mounts, host_mounts (RM-12)#31
AviBackToBlack merged 8 commits into
mainfrom
roadmap/RM-12-host-mounts

Conversation

@AviBackToBlack

@AviBackToBlack AviBackToBlack commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Why

A real MCP integration (@whdrnr2583/token-meter) needs to read %USERPROFILE%\.claude and
%USERPROFILE%\.codex to ingest real session history — directories that are neither the project
tree, an argument the tool receives, nor something project_volumes/shared_volumes can express
(those are Docker-managed named volumes, not host bind mounts). ContainerBin had no declarative
way for a trusted profile to say "mount this exact host path here, with this access mode."

Filed as issue #29 / RM-12 in the roadmap (issue #2). This PR expands the trust boundary
deliberately and narrowly, with stricter rules than the existing inferred-argument mount
mechanism, per the issue's own explicit security requirements.

What changed

New provider-agnostic host_mounts registry field:

[tools.token-meter]
image = "example/token-meter:latest"
provider = "stateless"
host_mounts = [
  "%USERPROFILE%\.claude:/root/.claude:ro",
  "%USERPROFILE%\.codex:/root/.codex:ro",
]

Key design decisions:

  • Reuses this project's own NAME:/container/path convention, extended with a required
    :MODE suffix — not Docker Compose's HOST:CONTAINER:MODE triplet, which is ambiguous against
    a literal Windows source path that already contains a colon (D:\Video). The existing
    ParseVolumeBinding "first :/" delimiter trick (already used by project_volumes/
    shared_volumes) solves this cleanly and is reused verbatim.
  • Mode (ro/rw) is required, never defaulted — every mount's write access is visible by
    reading the registry line.
  • Exactly one host variable is understood: %USERPROFILE%, only at the start of the source,
    with no other %...% token permitted anywhere else in it. Anything else is rejected outright —
    deliberately not a general expansion mechanism.
  • Two-stage validation: static structural checks (target must be absolute, must not fall
    under the reserved /workspace//cb namespaces, no duplicate targets, no collision with a
    declared volume destination) happen at registry-load time with no filesystem/env access;
    environment-dependent resolution (variable expansion, canonicalization through the existing
    pathmap.CanonicalPath, UNC rejection, existence check) happens at RunTool time and fails
    closed.
  • No second path-normalization implementation — reuses pathmap.CanonicalPath exactly.
  • MountSpec's existing signature and every call site are untouched. A new MountSpecMode
    was added alongside it, not as a replacement.
  • Diagnostics: cb inspect (raw declared entry) and cb trace (expanded/resolved entry, per
    each command's existing purpose) get new host_mount lines; cb doctor gets a new
    fail-closed-informing (warn, not fail — missing source is per-machine state, not a cb
    defect) existence check per declared mount, plus an optional bonus check for mapped-network
    drives reusing existing cb doctor machinery. cb bugreport needed no code changes — it
    already captures cb doctor's output through the existing redaction pass.
  • Out of scope, deliberately: cwd_mode (the related-but-orthogonal launcher-CWD problem,
    filed as its own follow-up in issue Roadmap: post-bootstrap improvements (audit follow-ups, categorized) #2); any change to internal/pathmap's existing
    argument-path inference; a Token-Meter-specific provider; general shell-style variable
    expansion; making rw implicit.

Pipeline

Implemented by SWE-1.7 Max (comparable rigor to RM-2/RM-9, given this is the first registry field
that intentionally exposes arbitrary host paths beyond the project/argument mounts), verified
(blind, pasted-context) by GLM-5.2 High — clean PASS, with one item GLM correctly flagged as a
low-confidence note it couldn't resolve without file access: whether project_volumes
destinations are always /workspace-prefixed, the assumption the collision check relied on to
justify checking only shared_volumes.

The orchestrator checked, and the assumption was wrongParseVolumeBinding places no such
requirement; it's only true by convention for this repo's built-in profiles. Fixed directly
(4b522b8): the collision check now covers both project_volumes and shared_volumes
destinations, with a regression test. This is exactly the class of gap a blind, no-file-access
verifier is structurally unable to close on its own, and why the orchestrator reviews every diff
independently rather than relaying agent reports.

Independently re-validated (not just the implementer's report) inside golang:1.24 at every
stage, including after the orchestrator's own fix:

gofmt -l . ; go vet ./... && go test -race ./...

All clean, all green.

Validation

MSYS_NO_PATHCONV=1 docker run --rm -v "D:\Work\GIT\container-bin:/src" -w /src -e GOFLAGS=-buildvcs=false golang:1.24 sh -c "gofmt -l . ; go vet ./... && go test -race ./..."

🤖 Generated with a multi-agent pipeline (SWE-1.7 Max implementer, GLM-5.2 High blind verifier, orchestrated by Claude Code)

Update: a serious bug was found and fixed after the above was written

Round 2's fix for the target-collision-detection gap (normalizing container paths with
path.Clean so equivalent forms like /root/.x and /root/./.x are correctly recognized as the
same target) had a side effect nobody caught in that round: path.Clean("/workspace/..") returns
/, and / was not itself in the reserved-namespace list. A host_mounts entry declaring
target /workspace/.. (or /cb/..) would clean to / and pass validation — mounting a host
directory at the container's filesystem root, shadowing every container-bin-managed mount. The
broadest possible target, on this PR's own trust-boundary feature.

Neither Devin Review nor Copilot caught this across three review passes. It was found by a
second, full-diff GLM re-verify pass run specifically because this task's trust-boundary bar
warranted not stopping at "the named findings were addressed." Fixed directly by the
orchestrator (given validateHostMounts had by then had two independent gaps found in it):
reject any literal .. path segment in a declared target before path.Clean ever runs, plus a
defense-in-depth rejection of a cleaned target equal to bare /. A third, narrowly-scoped GLM
pass focused specifically on this fix confirmed it closes the hole without introducing a new one,
and flagged one coverage gap (the defense-in-depth branch itself was untested), closed
immediately.

This is exactly the failure mode a trust-boundary feature needs guarding against: a fix for one
(lower-severity) finding opening a new, more severe one in shared validation logic. Full details
in .handoff/RM-12/r2-verdict.md and .handoff/RM-12/r3-verdict-focused.md.

AviBackToBlack and others added 3 commits August 19, 2026 22:55
- Registry: parse host_mounts as SOURCE:/CONTAINER:MODE, validate mode,
  reserved targets, shared volume collisions, and the narrow %USERPROFILE%
  variable surface.
- Runtime: expand %USERPROFILE%, canonicalize, and build bind mounts with
  explicit ro/rw in RunTool; reject UNC sources before docker run.
- CLI: print raw host_mounts in inspect and expanded/canonicalized ones
  in trace, with inline error reporting for trace diagnostics.
- Doctor: warn when a declared host_mount source does not exist or is
  unreachable, plus a non-blocking network-storage warning on Windows.
- Tests and docs: add unit coverage for parsing, mount construction,
  expansion, and trace/inspect output; document the field, its required
  mode, the single supported variable, and the trust-boundary note in
  README.md and docs/security-model.md.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The forward-slash drive form (D:/Video) embeds the :/ source/target
delimiter and is rejected as ambiguous; document the backslash form
as the supported shape.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… too

validateHostMounts only compared host_mounts targets against
shared_volumes destinations. ParseVolumeBinding places no requirement
that a project_volumes destination live under /workspace -- that's
only true by convention for this repo's built-in profiles, not
enforced by the schema -- so a custom stateful profile declaring e.g.
project_volumes = ["foo:/root/.foo"] could collide with a host_mounts
target at that same literal path with nothing catching it. Found
during the orchestrator's independent review after GLM-5.2's blind
verify (correctly) flagged it as a low-confidence note it couldn't
resolve without file access to internal/registry/registry.go's actual
ParseVolumeBinding implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

This comment was marked as resolved.

AviBackToBlack and others added 3 commits August 19, 2026 23:25
- Doctor: always call networkStorageVerdict for host_mounts sources; only
  gate the Windows drive-type probe on the UNC prefix, not the verdict.
- buildHostMountArgs: reject UNC paths after canonicalization so
  junctions/symlinks that resolve to UNC are caught.
- cb trace: annotate host_mount lines when the source would fail the UNC
  or existence gates that cb run enforces.
- hostMountVerdict: include the declared source string in ok/warn messages.
- validateHostMounts: reserve /venv and /root/.cache/pip, and normalize all
  container paths with path.Clean before duplicate/collision checks.
- ParseHostMount: return a path.Clean target so callers construct mounts
  matching the validated destination.
- Tests and docs: add coverage for the above and note that cb expose does
  not inherit host_mounts.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…an hide it

GLM-5.2's round-2 re-verify found a real bypass: path.Clean("/workspace/..")
== "/", which is not itself in validateHostMounts's reserved-namespace list.
The clean-then-compare order added to fix target-collision detection
(equivalent-path normalization) let a ".."-traversal target escape the
reserved-namespace check entirely and mount at the filesystem root -- which
would shadow every container-bin-managed mount (the project bind at
/workspace, state at /cb, /venv, path-mapper's /cb/mounts/N).

Fixed with two layers: reject any target containing a literal ".." path
segment outright, before path.Clean ever runs (there is no legitimate reason
for a host_mounts target to contain one); and reject a cleaned target that is
bare "/" as defense in depth, independent of how it got there. Added
regression tests for /workspace/.., /cb/.. and a bare mid-path ".." segment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t_mounts

GLM-5.2's focused re-verify of b71905f confirmed the fix closes the hole but
flagged the defense-in-depth target=="/" branch itself as untested: a
target of "/" or "/./" contains no literal ".." segment, so it passes
the traversal-rejection loop unrejected and is only caught by the separate
bare-root check. Locks that branch in so a future refactor assuming the
".." loop alone is sufficient breaks a test rather than silently reopening
the root-mount hole.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (4)

internal/diag/diag.go:361

  • mode is discarded here, so cb doctor (and therefore cb bugreport) reports the source, target, and existence status but omits the declared ro/rw access. The linked diagnostics requirement explicitly includes mode, and omitting it hides the most security-relevant part of a host mount. Preserve the parsed mode and include it in hostMountVerdict output and its tests.
			source, target, _, err := registry.ParseHostMount(spec)

internal/dockerrun/dockerrun.go:298

  • Every os.Stat failure is reported as “does not exist,” but errors such as access denied, an invalid path, or an unavailable device do not establish nonexistence. Keep failing closed, but reserve this message for errors.Is(statErr, os.ErrNotExist) and surface the actual error otherwise.
		if _, statErr := os.Stat(canon); statErr != nil {
			return nil, fmt.Errorf("host_mounts source %q does not exist: %s", source, canon)
		}

internal/cli/cli.go:117

  • This labels every os.Stat error as a missing source, so cb trace gives incorrect guidance for permission, device, or other filesystem failures. Distinguish os.ErrNotExist from other errors and include the latter error in the would-fail annotation.
			if _, statErr := os.Stat(expanded); statErr != nil {
				fmt.Printf("host_mount:  %s -> %s (%s) [would fail: source does not exist]\n", expanded, target, mode)

internal/diag/diag.go:377

  • Reducing statErr to a boolean makes doctor report every filesystem failure as “does not exist.” Access-denied and unavailable-device errors need a distinct warning containing the actual error; only os.ErrNotExist should use the missing-source verdict.
			_, statErr := os.Stat(canon)
			status, msg := hostMountVerdict(name, source, target, canon, statErr == nil)

devin-ai-integration[bot]

This comment was marked as resolved.

Devin Review found 5 more findings on the round-2 push, one a yellow-severity
bug in validateHostMounts (again -- third gap found in this function across
the review process):

1. (bug) /venv and /root/.cache/pip were reserved by exact match only, unlike
   /workspace and /cb which use prefix matching. A target like /venv/bin
   validated cleanly and only broke the python provider's bootstrap script at
   run time (it depends on /venv/bin/python specifically). Fixed by unifying
   all four reserved namespaces under one prefix-based pathContainsOrEquals
   helper. Deliberately NOT widened to a bidirectional containment check
   against arbitrary project_volumes/shared_volumes destinations -- a
   host_mounts entry nested under a user-declared volume at a different
   target is ordinary, valid Docker configuration (the more specific mount
   just shadows part of the outer one), same reasoning already applied when
   declining Copilot's parent/child-overlap suggestion in round 2.
2. cb doctor's host_mounts loop iterated reg.Tools in map order (nondeterministic,
   noisy for cb bugreport diffing). Sorted.
3. %USERPROFILE%foo (no separator) silently concatenated into a sibling
   directory (C:\Users\<user>foo) rather than a child, since
   ExpandHostMountSource does plain string concatenation. Now requires
   %USERPROFILE% to be the whole source or immediately followed by \ or /.
4. A comma in a host_mounts target was only caught at RunTool time via
   MountSpecMode, not at registry load. Mirrors the existing source-comma
   check.
5. cli_test.go's captureStdout is a copy of diag's; documented the same
   serial-execution precondition (unsafe with a future t.Parallel()) rather
   than deduplicating across packages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 4 new potential issues.

Open in Devin Review

Comment thread internal/registry/registry.go
Comment thread internal/registry/registry.go
Comment thread internal/dockerrun/dockerrun.go
Comment thread internal/diag/diag.go
Four Info-severity findings on the previous push, all small and independent
of the shared validation logic already fixed twice:

1. README only documented /workspace and /cb as reserved host_mounts
   namespaces, not /venv or /root/.cache/pip (added when the reservation
   check became prefix-based). Updated to name all four.
2. A colon inside a host_mounts target was silently absorbed by the
   LastIndex-based mode split (e.g. "D:\V:/root/a:b:ro" parsed as target
   "/root/a:b", mode "ro") -- not a mis-mount (Docker tolerates a colon in
   dst=), but outside the documented SOURCE:/CONTAINER_PATH:MODE grammar.
   Rejected, mirroring the existing comma check. GLM's fourth verify pass
   flagged the identical shape independently and judged it non-blocking; two
   reviewers agreeing it's real, even if low severity, was enough to fix it.
3. (no fix) Devin independently confirmed host_mounts targets can't collide
   with path-mapper's argument-derived /cb/mounts/N or /workspace mounts,
   since both fall under the reserved-namespace check. Acknowledged, no
   action needed.
4. cb doctor's missing-source warning and RunTool's hard failure use the same
   condition but different severities (warn vs fail) by design (per-machine
   state vs cb defect) -- but the warn message didn't say the tool actually
   can't run, so a clean doctor summary could misleadingly read as healthy.
   Strengthened the wording without changing the severity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@AviBackToBlack
AviBackToBlack merged commit 50cc9b4 into main Aug 19, 2026
7 checks passed
@AviBackToBlack
AviBackToBlack deleted the roadmap/RM-12-host-mounts branch August 19, 2026 23:09
AviBackToBlack added a commit that referenced this pull request Aug 19, 2026
Post-merge doc audit found host_mounts (RM-12, PR #31) had no mention in
architecture.md at all -- not in the dispatch pipeline diagram, not as its
own section -- even though it's a new mount mechanism in that pipeline.
README.md and docs/security-model.md were already updated in PR #31; this
fills the one doc that covers pipeline/mechanism placement specifically.
Points at README/security-model for the field syntax and trust-boundary
discussion rather than restating either, per this repo's own established
convention against duplicate sources of truth that drift.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants