feat: explicit profile-level host mounts, host_mounts (RM-12) - #31
Merged
Conversation
- 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>
- 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>
There was a problem hiding this comment.
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
modeis discarded here, socb doctor(and thereforecb bugreport) reports the source, target, and existence status but omits the declaredro/rwaccess. 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 inhostMountVerdictoutput and its tests.
source, target, _, err := registry.ParseHostMount(spec)
internal/dockerrun/dockerrun.go:298
- Every
os.Statfailure 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 forerrors.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.Staterror as a missing source, socb tracegives incorrect guidance for permission, device, or other filesystem failures. Distinguishos.ErrNotExistfrom 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
statErrto 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; onlyos.ErrNotExistshould use the missing-source verdict.
_, statErr := os.Stat(canon)
status, msg := hostMountVerdict(name, source, target, canon, statErr == nil)
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>
58 tasks
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>
This was referenced Aug 19, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A real MCP integration (
@whdrnr2583/token-meter) needs to read%USERPROFILE%\.claudeand%USERPROFILE%\.codexto ingest real session history — directories that are neither the projecttree, an argument the tool receives, nor something
project_volumes/shared_volumescan 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_mountsregistry field:Key design decisions:
NAME:/container/pathconvention, extended with a required:MODEsuffix — not Docker Compose'sHOST:CONTAINER:MODEtriplet, which is ambiguous againsta literal Windows source path that already contains a colon (
D:\Video). The existingParseVolumeBinding"first:/" delimiter trick (already used byproject_volumes/shared_volumes) solves this cleanly and is reused verbatim.ro/rw) is required, never defaulted — every mount's write access is visible byreading the registry line.
%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.
under the reserved
/workspace//cbnamespaces, no duplicate targets, no collision with adeclared 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 atRunTooltime and failsclosed.
pathmap.CanonicalPathexactly.MountSpec's existing signature and every call site are untouched. A newMountSpecModewas added alongside it, not as a replacement.
cb inspect(raw declared entry) andcb trace(expanded/resolved entry, pereach command's existing purpose) get new
host_mountlines;cb doctorgets a newfail-closed-informing (
warn, notfail— missing source is per-machine state, not a cbdefect) existence check per declared mount, plus an optional bonus check for mapped-network
drives reusing existing
cb doctormachinery.cb bugreportneeded no code changes — italready captures
cb doctor's output through the existing redaction pass.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 existingargument-path inference; a Token-Meter-specific provider; general shell-style variable
expansion; making
rwimplicit.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_volumesdestinations are always
/workspace-prefixed, the assumption the collision check relied on tojustify checking only
shared_volumes.The orchestrator checked, and the assumption was wrong —
ParseVolumeBindingplaces no suchrequirement; it's only true by convention for this repo's built-in profiles. Fixed directly
(
4b522b8): the collision check now covers bothproject_volumesandshared_volumesdestinations, 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.24at everystage, including after the orchestrator's own fix:
All clean, all green.
Validation
🤖 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.Cleanso equivalent forms like/root/.xand/root/./.xare correctly recognized as thesame target) had a side effect nobody caught in that round:
path.Clean("/workspace/..")returns/, and/was not itself in the reserved-namespace list. Ahost_mountsentry declaringtarget
/workspace/..(or/cb/..) would clean to/and pass validation — mounting a hostdirectory 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
validateHostMountshad by then had two independent gaps found in it):reject any literal
..path segment in a declared target beforepath.Cleanever runs, plus adefense-in-depth rejection of a cleaned target equal to bare
/. A third, narrowly-scoped GLMpass 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.mdand.handoff/RM-12/r3-verdict-focused.md.