Skip to content
Merged
58 changes: 49 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,55 @@ env_prefixes = ["TF_", "AWS_", "ARM_"] # only these host vars enter the contain

Semantics include `command`, `args_prefix`, `path_next`, `path_equals`,
`path_last`, `path_last_if_any`, `env_names`, `env_prefixes`, `env_set`,
`project_markers`, `state_group`, `project_volumes` and `shared_volumes`.
Unknown keys **fail validation** instead of being silently ignored, and a
`schema_version` newer than the binary supports fails closed. Edit the file,
then run `cb install` to reconcile shims.

One exception applies to every path-forcing key (`path_next`, `path_equals`,
`path_last`): a value whose final element is `...` is never rewritten, because
Windows cannot represent such a directory and rewriting it would silently
change which files the tool acts on. See [Windows path mapping](#windows-path-mapping).
`project_markers`, `state_group`, `project_volumes`, `shared_volumes` and
`host_mounts`. Unknown keys **fail validation** instead of being silently
ignored, and a `schema_version` newer than the binary supports fails closed.
Edit the file, then run `cb install` to reconcile shims.

### Explicit host bind mounts (`host_mounts`)

`host_mounts` lets a trusted profile declare fixed host paths that are always
bind-mounted into the container, regardless of whether they appear in the
command line. This is provider-agnostic: it works for `stateless`, `python`
and `stateful` profiles alike.

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

Entries follow the `SOURCE:/CONTAINER_PATH:MODE` shape used by
`project_volumes`/`shared_volumes`, extended with a required third `:MODE`
segment. `ro` and `rw` are accepted; there is **no default** — every mount's
write access must be explicit in the registry line.

`%USERPROFILE%` is the only host variable container-bin expands; no other
`%...%` token is recognized or guessed. The source may also be a literal
Windows absolute path using a backslash after the drive letter
(e.g. `D:\Video`). The forward-slash drive form (`D:/Video`) embeds the `:/`
source/target delimiter and is rejected as ambiguous, so always use `X:\...`.

Targets under `/workspace`, `/cb`, `/venv` and `/root/.cache/pip` are reserved
for container-bin's own project workspace and managed state mounts (the last
two are the python provider's fixed venv/pip-cache paths) and cannot be
claimed by `host_mounts`, on any provider.

> A `host_mounts` entry grants the configured Docker image direct access to the
> named host files or directories. ContainerBin is **not a security sandbox**;
> a `rw` mount exposes those host files to any code running in the container,
> exactly as a `docker run --mount` would. Use `ro` when the tool only needs to
> read, review every `rw` mount, and keep the registry and shim directory
> under your control.
>
> Exposed profiles created by `cb expose` from an npm-shaped source profile do
> **not** inherit that source's `host_mounts`; host access never propagates
> implicitly to an auto-generated shim. A profile that genuinely needs a host
> mount must declare it explicitly.

## Windows path mapping

Expand Down
11 changes: 8 additions & 3 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ mounted in, and the environment variables your profiles select passed through.
Inside the boundary (whoever controls these controls execution):

- `container-bin.toml` — names the images, the env allowlists, the volumes,
the path semantics. Arbitrary registry write access ≈ arbitrary code
execution with your Docker privileges.
the `host_mounts`, and the path semantics. Arbitrary registry write access
≈ arbitrary code execution with your Docker privileges; `host_mounts` with
`rw` access hand the image write access to those host paths.
- `container-bin.lock` — pins image digests. Whoever can rewrite it can pin a
malicious digest.
- The shim directory on `PATH` — whoever can write executables there doesn't
Expand All @@ -31,7 +32,11 @@ readable, and dangerous to let others edit.
- **Narrow mounts.** The project root is mounted; paths outside it get
individual narrow bind mounts (for files: the parent directory; for
not-yet-existing outputs: nearest existing ancestor). Whole drives are never
mounted because one argument lives on them.
mounted because one argument lives on them. `host_mounts` are the explicit,
mode-required exception: a trusted profile can declare a fixed host source
and container target, but every entry must spell out `ro` or `rw` and is
validated as a `SOURCE:/CONTAINER_PATH:MODE` binding with the same fail-closed
checks as the volume fields.
- **Conservative path rewriting.** Arguments are only treated as paths when
their shape is unambiguous or the tool profile explicitly declares the
semantics. Unknown strings pass through untouched.
Expand Down
29 changes: 29 additions & 0 deletions internal/cli/cli.go
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@ func Trace(reg registry.Registry, args []string) error {
fmt.Printf("shared_volume: %s -> %s\n", pathmap.StatefulSharedVolumeID(t.StateGroup, name), dst)
}
}
for _, spec := range t.HostMounts {
source, target, mode, err := registry.ParseHostMount(spec)
if err != nil {
return err
}
expanded, err := dockerrun.ExpandHostMountSource(source)
if err == nil {
expanded, err = pathmap.CanonicalPath(expanded)
}
switch {
case err != nil:
fmt.Printf("host_mount: %s -> %s (%s) [resolve error: %v]\n", source, target, mode, err)
case strings.HasPrefix(expanded, `\\`):
fmt.Printf("host_mount: %s -> %s (%s) [would fail: resolves to a UNC path, which Docker Desktop cannot share]\n", expanded, target, mode)
default:
if _, statErr := os.Stat(expanded); statErr != nil {
fmt.Printf("host_mount: %s -> %s (%s) [would fail: source does not exist]\n", expanded, target, mode)
} else {
fmt.Printf("host_mount: %s -> %s (%s)\n", expanded, target, mode)
}
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
return nil
}

Expand Down Expand Up @@ -314,6 +336,13 @@ func Inspect(reg registry.Registry, args []string) error {
}
fmt.Printf("shared_volume: %s -> %s\n", pathmap.StatefulSharedVolumeID(t.StateGroup, logical), dst)
}
for _, spec := range t.HostMounts {
source, target, mode, e := registry.ParseHostMount(spec)
if e != nil {
return e
}
fmt.Printf("host_mount: %s -> %s (%s)\n", source, target, mode)
}
if t.Provider == "python" {
fmt.Printf("python_env: %s\npip_cache: cb-pip-cache\n", pathmap.PythonEnvID(root, found))
}
Expand Down
218 changes: 218 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package cli

import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"

"github.com/AviBackToBlack/container-bin/internal/pathmap"
"github.com/AviBackToBlack/container-bin/internal/registry"
)

Expand Down Expand Up @@ -87,3 +90,218 @@ func TestRenderExposedToolSection(t *testing.T) {
t.Errorf("command = %v, want %v", got.Command, wantCommand)
}
}

// captureStdout redirects os.Stdout to a temp file for the duration of fn,
// then restores it and returns everything fn wrote. Mirrors the helper in
// internal/diag so the new inspect/trace output lines can be asserted without
// changing those functions' signatures just for tests. Safe only because
// none of this package's tests call t.Parallel() — it swaps the process-
// global os.Stdout, so a parallel test using it would interleave captures
// silently; keep every test using it serial (same precondition
// internal/diag's copy documents at its own call site).
func captureStdout(fn func() error) (string, error) {
f, err := os.CreateTemp("", "cb-cli-test-")
if err != nil {
return "", err
}
tmpPath := f.Name()
defer os.Remove(tmpPath)

real := os.Stdout
os.Stdout = f
defer func() { os.Stdout = real }()

_ = fn()

if cerr := f.Close(); cerr != nil {
return "", cerr
}
data, rerr := os.ReadFile(tmpPath)
if rerr != nil {
return "", rerr
}
return string(data), nil
}
Comment thread
AviBackToBlack marked this conversation as resolved.

func setTestHome(t *testing.T, dir string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", dir)
} else {
t.Setenv("HOME", dir)
}
}

func TestInspectPrintsHostMountRaw(t *testing.T) {
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte{}, 0644); err != nil {
t.Fatal(err)
}

reg, err := registry.ParseTOML(`[tools.demo]
image = "demo:1"
provider = "stateless"
host_mounts = ["%USERPROFILE%\\.claude:/root/.claude:ro"]
`)
if err != nil {
t.Fatalf("parse registry: %v", err)
}

out, err := captureStdout(func() error { return Inspect(reg, []string{"demo"}) })
if err != nil {
t.Fatalf("capture: %v", err)
}
if !strings.Contains(out, "host_mount: %USERPROFILE%\\.claude -> /root/.claude (ro)") {
t.Fatalf("inspect output missing raw host_mount line:\n%s", out)
}
}

func TestTracePrintsHostMountExpanded(t *testing.T) {
dir := t.TempDir()
homeDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(homeDir, ".claude"), 0755); err != nil {
t.Fatal(err)
}
setTestHome(t, homeDir)

old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte{}, 0644); err != nil {
t.Fatal(err)
}

reg, err := registry.ParseTOML(`[tools.demo]
image = "demo:1"
provider = "stateless"
host_mounts = ["%USERPROFILE%/.claude:/root/.claude:ro"]
`)
if err != nil {
t.Fatalf("parse registry: %v", err)
}

expectedCanon, err := pathmap.CanonicalPath(homeDir + "/.claude")
if err != nil {
t.Fatal(err)
}

out, err := captureStdout(func() error { return Trace(reg, []string{"demo"}) })
if err != nil {
t.Fatalf("capture: %v", err)
}
want := "host_mount: " + expectedCanon + " -> /root/.claude (ro)"
if !strings.Contains(out, want) {
t.Fatalf("trace output missing expanded host_mount line (want %q):\n%s", want, out)
}
}

func TestTraceHostMountResolveErrorIsPrintedNotFatal(t *testing.T) {
dir := t.TempDir()
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", "")
} else {
t.Setenv("HOME", "")
}

old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte{}, 0644); err != nil {
t.Fatal(err)
}

reg, err := registry.ParseTOML(`[tools.demo]
image = "demo:1"
provider = "stateless"
host_mounts = ["%USERPROFILE%/.claude:/root/.claude:ro"]
`)
if err != nil {
t.Fatalf("parse registry: %v", err)
}

out, err := captureStdout(func() error { return Trace(reg, []string{"demo"}) })
if err != nil {
t.Fatalf("capture: %v", err)
}
if !strings.Contains(out, "host_mount: %USERPROFILE%/.claude -> /root/.claude (ro)") {
t.Fatalf("trace output missing host_mount line:\n%s", out)
}
if !strings.Contains(out, "resolve error:") {
t.Fatalf("trace output missing resolve error marker:\n%s", out)
}
}

func TestTraceHostMountMissingSourceWouldFail(t *testing.T) {
dir := t.TempDir()
homeDir := t.TempDir()
setTestHome(t, homeDir)

old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte{}, 0644); err != nil {
t.Fatal(err)
}

reg, err := registry.ParseTOML(`[tools.demo]
image = "demo:1"
provider = "stateless"
host_mounts = ["%USERPROFILE%/does-not-exist:/root/missing:ro"]
`)
if err != nil {
t.Fatalf("parse registry: %v", err)
}

out, err := captureStdout(func() error { return Trace(reg, []string{"demo"}) })
if err != nil {
t.Fatalf("capture: %v", err)
}
if !strings.Contains(out, "[would fail: source does not exist]") {
t.Fatalf("trace output missing would-fail annotation:\n%s", out)
}
}

func TestTraceHostMountUNCWouldFail(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("UNC resolution is only meaningful on Windows")
}
dir := t.TempDir()
t.Setenv("USERPROFILE", `\\server\share\home`)

old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte{}, 0644); err != nil {
t.Fatal(err)
}

reg, err := registry.ParseTOML(`[tools.demo]
image = "demo:1"
provider = "stateless"
host_mounts = ["%USERPROFILE%/.claude:/root/.claude:ro"]
`)
if err != nil {
t.Fatalf("parse registry: %v", err)
}

out, err := captureStdout(func() error { return Trace(reg, []string{"demo"}) })
if err != nil {
t.Fatalf("capture: %v", err)
}
if !strings.Contains(out, "[would fail: resolves to a UNC path, which Docker Desktop cannot share]") {
t.Fatalf("trace output missing UNC would-fail annotation:\n%s", out)
}
}
Loading