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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

*.go text eol=lf
*.md text eol=lf
*.py text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
run: go vet ./...
- name: go test (race)
run: go test -race ./...
- name: Test embedded pipx wrapper
run: python3 -m unittest -v internal/registry/pipx_wrapper_test.py internal/cli/pipx_discovery_test.py
- name: Verify release-style version injection
run: |
# The binary MUST be named cb: argv[0] dispatch treats any other
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
*.out
/dist/

# Python wrapper-test artifacts
__pycache__/
*.pyc

# Editor / OS noise
.vscode/
.idea/
Expand Down
94 changes: 80 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
**Run CLI tools on Windows through Docker-backed executable shims — without
installing the runtimes on the host.**

ContainerBin makes commands such as `python`, `pip`, `node`, `npm`, `npx`,
ContainerBin makes commands such as `python`, `pip`, `pipx`, `node`, `npm`, `npx`,
`uv`, `uvx`, `go`, `cargo`, `rustc`, `dotnet`, `ruby`, `gem`, `bundle`, `jq`, `yq`, `terraform` and `ffmpeg` look like ordinary
Windows executables while their real implementations run inside disposable
Linux containers on Docker Desktop. Your Windows installation stays clean: no
Python, Node, Go, Rust, uv, .NET SDK or Ruby on the host — just one small Go binary,
Python, pipx, Node, Go, Rust, uv, .NET SDK or Ruby on the host — just one small Go binary,
`cb.exe`.

```powershell
Expand Down Expand Up @@ -125,6 +125,7 @@ cb lock
| `rustc` | `rust:1.98.1-slim-bookworm` | stateless |
| `cargo` | `rust:1.98.1-slim-bookworm` | stateful (`rust198` state group) |
| `uv`, `uvx` | `ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim` | stateful (`uv012-py313` state group) |
| `pipx` | `ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim` | stateful (`pipx117-py313` state group; pinned `pipx==1.17.4`) |
| `dotnet` | `mcr.microsoft.com/dotnet/sdk:10.0` | stateful (`dotnet10` state group) |
| `ruby`, `gem`, `bundle` | `ruby:4.0-trixie` | stateful (`ruby40` state group) |
| `jq` | `ghcr.io/jqlang/jq:latest` | stateless |
Expand Down Expand Up @@ -405,6 +406,62 @@ Existing installations gain `uv` and `uvx` on `cb install`. An older lockfile
does not include their image, so run `cb update uv` (or regenerate the lock with
`cb lock`) before first use in locked mode.

## pipx global application state

`pipx` is the classic-Python global application workflow. It is a separate
stateful profile: installed application environments live under
`/cb/pipx/home`, their executables live under `/cb/pipx/bin`, and the pinned
pipx launcher cache lives under `/cb/pipx/launcher-cache`. These directories
share one managed state volume so their relative links and cached launcher
remain portable together. That volume is not the Python provider's project
`/venv` or pip cache, and it is not shared with uv's tool store.

The locked uv/Python image launches the exact `pipx==1.17.4` release with
`uvx`. First use therefore needs package-index access to populate the dedicated
launcher cache; later invocations can use that cache offline. The image lock
pins the launcher image, while package-index trust and the pipx package download
remain governed by the profile's narrowly forwarded uv/pip index, TLS and proxy
settings. Automatic Python downloads are disabled and pipx uses the Python 3.13
interpreter already in the locked image. A volume-local lock serializes pipx
commands that share this state. After every command, including failed commands,
a fail-closed wrapper validates every pipx-owned symlink, changes links that
resolve within the state volume to relative links, and copies only the known
image interpreter into its launcher cache, application venvs, and the pip
backend's shared-libraries venv. This
includes explicit `--backend pip` installs and pipx's forced pip backend for
the `pip` package while still rejecting any other external link target. This
keeps `cb-pipx117-py313-state` portable
through selected-volume backup/restore without relaxing archive link validation.
`pipx run` environments remain ephemeral because `PIPX_VENV_CACHEDIR` is not
placed on the managed volume; each `pipx run` may resolve and download its
application again and therefore requires the configured package index to be
reachable.

```powershell
pipx install cowsay==6.1
cb expose pipx cowsay # explicit deterministic selection
cowsay "hello from pipx"

cb expose pipx # expose every other eligible app in the store
cb unexpose cowsay
```

The generated application profiles preserve the pipx image, state group,
volumes and environment policy. Exposure discovery reads only the managed bin
directory within the state volume through the selected locked profile; it does
not search a project venv, the host `PATH`, uv's store, or other container
directories. Each discovered executable must also resolve inside the managed
pipx state volume, so an external link left by a failed install is rejected.
Discovery takes a shared lock on the same volume-local lock used exclusively by
pipx commands, so it never scans a partially updated application store.
Plain `pip` remains for project dependencies, so scripts in `/venv/bin` are
deliberately not eligible for global exposure.

Existing installations gain `pipx` on `cb install`. Because it shares the same
image reference as uv, a lock that already contains that reference can resolve
it; otherwise run `cb update pipx` (or regenerate the lock with `cb lock`) before
first use in locked mode.

## .NET SDK state

`dotnet` uses Microsoft's .NET 10 LTS SDK image. NuGet packages, user-level
Expand Down Expand Up @@ -487,6 +544,10 @@ uv tool install ruff
cb expose uvx ruff
ruff --version

pipx install cowsay==6.1
cb expose pipx cowsay
cowsay "hello from pipx"

dotnet tool install --global dotnet-ef
cb expose dotnet dotnet-ef
dotnet-ef --version
Expand All @@ -503,8 +564,9 @@ acme-lint --version

`cb expose` takes a stateful source profile with one supported global binary
store: the npm prefix (`npm`, `npm22`, ...), Go's shared `/go/bin` (`go`),
Cargo's install root (`cargo`), uv's tool-bin directory (`uv`, `uvx`), .NET's
global tool home (`dotnet`), or the RubyGems home (`ruby`, `gem`, `bundle`). It
Cargo's install root (`cargo`), uv's tool-bin directory (`uv`, `uvx`), pipx's
managed bin directory (`pipx`), .NET's global tool home (`dotnet`), or the
RubyGems home (`ruby`, `gem`, `bundle`). It
adds registry profiles that inherit the source image, `state_group`,
project-root markers and mode, shared volumes, and environment policy, then
creates Windows shims — `cowsay.exe`, `stringer.exe`, `just.exe`, `ruff.exe`,
Expand All @@ -514,16 +576,20 @@ expose a binary installed under the Node 22 runtime, use
`cb expose npm22 <binary>`; for `go install` output, use
`cb expose go <binary>`; for `cargo install`, use
`cb expose cargo <binary>`; for `uv tool install`, use
`cb expose uvx <binary>`; for a global .NET tool, use
`cb expose uvx <binary>`; for a pipx application, use
`cb expose pipx <binary>`; for a global .NET tool, use
`cb expose dotnet <binary>`; for a Ruby gem executable, use
`cb expose ruby <binary>`.

Managed-store discovery uses the already-locked local source image with pulls
and networking disabled, a read-only container root, an explicit shell
entrypoint, and read-only mounts for the selected store and any required
companion volume. The source image must provide a POSIX-compatible `sh`;
distroless images without one cannot use automatic discovery. Discovery never
mutates package-manager state.
and networking disabled, a read-only container root, and read-only mounts for
the selected store and any required companion volume. It uses an explicit shell
entrypoint except for pipx, whose locked Python image runs the embedded
lock-aware scanner directly. The pipx wrapper creates its reserved lock before
the first state mutation; discovery reports no applications if that lock does
not exist yet, otherwise it scans under a shared lock. No discovery path mutates
package-manager state. Other source images must provide a POSIX-compatible
`sh`; distroless images without one cannot use automatic discovery.

For a custom stateful profile, `cb expose --shared-file TOOL VOLUME FILE`
selects one logical name from that profile's `shared_volumes` and one absolute
Expand Down Expand Up @@ -851,10 +917,10 @@ benchmark methodology and the disposable-container tradeoff are in
executable, e.g. `$env:GOOS="windows"; go build`. `go test` must remain
native to the container because a Windows test binary cannot run inside it.
- `cb expose` supports the npm global prefix, Go's shared `/go/bin`, Cargo's
managed install root, uv's pipx-style tool bin, .NET's global tool home, and
RubyGems executables, plus an explicitly named executable beneath any
declared shared volume; direct pip/pipx environments outside uv's managed
tool store are not supported.
managed install root, uv's tool bin, pipx's managed application bin, .NET's
global tool home, and RubyGems executables, plus an explicitly named
executable beneath any declared shared volume; plain pip project environments
and unmanaged pipx stores are not supported.

## Roadmap

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ Three providers own lifecycle semantics:
`python -m pip` inside the same environment.
- **stateful** — generic declarative provider: `state_group` namespacing,
`project_volumes` (scoped per project root), `shared_volumes`. Node/npm/npx,
go/gofmt, cargo, uv/uvx, dotnet, ruby/gem/bundle and everything `cb expose`
creates use this.
go/gofmt, cargo, uv/uvx, pipx, dotnet, ruby/gem/bundle and everything
`cb expose` creates use this.

## Project roots and volume naming

Expand Down
5 changes: 5 additions & 0 deletions docs/release-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ mode:
- `pip install requests`
- `terraform -chdir=.\tf validate`

When qualifying a release that changes managed application exposure, also
exercise the pipx path end to end: install a disposable app, run both
`cb expose pipx APP` and store-wide `cb expose pipx`, invoke the generated
shim, then remove it with `cb unexpose APP`.

`cb self-test` alone does not fully replace this, because it uses its own
temp project volumes and already-local locked images rather than a first-run
user project.
Expand Down
30 changes: 24 additions & 6 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,17 @@ readable, and dangerous to let others edit.
- **Read-only exposure discovery.** `cb expose` requires the locked source image
to exist locally, disables pulls and networking, uses a read-only container
root and volume mounts, and overrides the image entrypoint with the discovery
shell. The source image must provide a POSIX-compatible `sh`; distroless
images without one cannot use automatic discovery. Explicit shared-file
shell or pipx's embedded Python scanner. Non-pipx source images must provide a
POSIX-compatible `sh`; distroless images without one cannot use automatic
discovery. Explicit shared-file
discovery also rejects a final symlink or a parent directory that resolves
outside the selected volume mount.
- **Serialized pipx exposure.** Pipx commands create and hold the volume-local
lock exclusively before mutating state. Discovery mounts the state read-only,
reports no applications when the lock does not exist yet, and otherwise holds
it shared while scanning. This prevents exposure from observing partially
updated application state without granting the discovery container write
access.
- **Validated atomic writes.** Registry/lock mutations parse the complete
resulting file before atomically replacing the original; backups are
restored the same way and only with `--apply`.
Expand All @@ -84,10 +91,21 @@ readable, and dangerous to let others edit.
project (and any explicitly referenced external paths) read-write, plus the
allowlisted environment variables. `cb lock` gives you *reproducibility* —
the same digest every time — not *safety* of that digest's contents.
- **Malicious packages.** `pip install`, `npm install -g`, and `cargo install`
execute inside containers, but the packages can read/write the mounted project
and persist in state volumes; an exposed global binary runs whenever you
invoke its shim.
- **Malicious packages.** `pip install`, `pipx install`, `npm install -g`, and
`cargo install` execute inside containers, but the packages can read/write the
mounted project and persist in state volumes; an exposed global binary runs
whenever you invoke its shim.
- **pipx launcher bootstrap.** The pipx profile's image digest is locked, but
its exact-version `pipx==1.17.4` launcher is populated from the configured
Python package index into a dedicated cache on first use. Index overrides,
TLS settings and proxies therefore remain part of that bootstrap's trust
boundary; the image lock does not attest package-index artifacts.
- **Fail-closed pipx link normalization.** After every pipx command, including
failed commands and interrupts, the
profile makes pipx-owned absolute links relative within its one state volume
and copies the exact image interpreter only at known venv/cache locations.
Any other absolute link fails the command; archive traversal checks remain
unchanged.
- **Secrets you pass through.** `env_prefixes = ["AWS_"]` exists so Terraform
can authenticate — which means your AWS credentials enter that container.
That is the feature working as designed; scope prefixes deliberately.
Expand Down
Loading
Loading