diff --git a/README.md b/README.md index 8eca054..d8abc3d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ real Linux CLI/runtime in an ephemeral container |---|---| | Windows 10/11 x64 + Docker Desktop (Linux containers) + PowerShell | **Supported** — this is the validated configuration | | cmd.exe invocation of shims | Works for the common cases; less battle-tested than PowerShell | +| WSL2 | **Not yet supported.** The selected native-Linux frontend now has an explicit fail-closed runtime boundary; config/shim/state implementation and real Docker Desktop WSL qualification remain. See [docs/wsl.md](docs/wsl.md) | | Windows 11 ARM64 | **CI-qualified only, not supported yet.** Native tests/build/dispatch run on GitHub-hosted ARM64 hardware, but there is no release artifact or real Docker Desktop ARM64 E2E qualification | | Linux / macOS hosts | **Not supported.** The program is Go and cross-compiles, but shim installation, path mapping and doctor checks are Windows-specific | | Windows containers | Not supported; images are Linux images | @@ -937,7 +938,9 @@ benchmark methodology and the disposable-container tradeoff are in ## Current limitations -- Windows x64 + Docker Desktop (Linux containers) only. Windows ARM64 has +- Windows x64 + Docker Desktop (Linux containers) only. WSL2 runtime detection is + present, but native WSL execution remains gated until its host layout, state + namespace and Docker Desktop qualification slices land. Windows ARM64 has native non-Docker CI coverage, but no published artifact or support claim. - First invocation of a tool after `cb lock` may still need images present locally (`cb lock` pulls them; `cb self-test` never pulls). diff --git a/docs/architecture.md b/docs/architecture.md index be9871b..7751f3f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,6 +9,7 @@ is configuration (`container-bin.toml`), a generated lockfile ``` NAME.exe (hardlink to cb.exe) → argv[0] dispatch main() inspects its own invocation name + → host runtime boundary reject unsupported frontends before config I/O → machine policy load fixed admin path, ownership/version validated → registry profile lookup container-bin.toml, schema-validated, fail-closed → argv normalization repair PowerShell-split "-opt=" "value" pairs @@ -230,7 +231,7 @@ for orientation, not a claim that every package depends on every package in the tier below it; see the exact edges further down for that): ``` -main argv[0] dispatch, subcommand switch, version, usage, +main argv[0] dispatch, host boundary, subcommand switch, version, usage, exit codes + fatalf/osExit, bootstrap self-update selection, withMutationLock's signal wrapper ↓ @@ -258,6 +259,7 @@ internal/registry Tool/Registry, TOML parser, defaults, registry file internal/toml the shared TOML subset lexer (leaf) internal/atomicio crash-safe write + .bak recovery (leaf) internal/mutationlock the registry mutation lock primitive (leaf) +internal/hostenv Windows/WSL/Linux runtime classification (leaf) internal/selfupdate canonical release selection and read-only plan (leaf) ``` @@ -265,7 +267,7 @@ The exact import edges, from `go list -f '{{.ImportPath}} {{.Imports}}' ./...`, project-internal imports only: ``` -main -> cli, diag, dockerrun, mutationlock, policy, registry, selfupdate, state +main -> cli, diag, dockerrun, hostenv, mutationlock, policy, registry, selfupdate, state cli -> atomicio, diag, dockerrun, lockfile, pathmap, policy, registry, statearchive, toml diag -> dockerrun, dockervol, lockfile, pathmap, policy, registry dockerrun -> dockervol, lockfile, pathmap, policy, registry @@ -275,7 +277,7 @@ lockfile -> atomicio, policy, registry, toml pathmap -> registry registry -> atomicio, toml policy -> toml -atomicio, dockervol, mutationlock, selfupdate, toml -> (leaves) +atomicio, dockervol, hostenv, mutationlock, selfupdate, toml -> (leaves) ``` Notably: `lockfile` and `pathmap` both depend on `registry` directly, not on @@ -301,9 +303,10 @@ release workflow inject it with `-ldflags "-X main.version=..."`, so that symbol path is part of the release contract. Packages that need it take it as a parameter. -`cb self-update --check` is dispatched before machine policy and registry -loading, like the bootstrap help/version path. Release selection therefore -remains available when either local configuration source is missing or invalid. +After the host runtime boundary is enforced, `cb self-update --check` is +dispatched before machine policy and registry loading. Release selection +therefore remains available when either local configuration source is missing +or invalid without allowing unsupported frontends to perform network work. `internal/selfupdate` has no project imports and performs only bounded metadata queries and plan output; downloading, attestation verification and installed-file replacement remain separate later phases. diff --git a/docs/security-model.md b/docs/security-model.md index 8645ebe..ab171d1 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -49,6 +49,12 @@ readable, and dangerous to let others edit. - **Fail-closed configuration.** Unknown registry keys, duplicate tool sections, newer schema versions, incomplete lock entries, and registry-image-not-in-lock all refuse to run rather than guess. +- **Fail-closed host boundary.** Non-bootstrap work currently runs only in a + native Windows process. Windows binaries launched through detected WSL + interoperability, WSL1, recognized-but-not-yet-enabled native WSL2, + standalone Linux and other hosts refuse before registry or Docker work. + WSL2 classification requires Microsoft WSL2 kernel markers; environment + variables alone cannot turn ordinary Linux into a supported host. - **Machine policy cannot be redirected or weakened.** A present enterprise policy is loaded only from the fixed OS path, requires administrator/root ownership and restrictive permissions, and authorizes the already-resolved diff --git a/docs/wsl.md b/docs/wsl.md new file mode 100644 index 0000000..63e2bac --- /dev/null +++ b/docs/wsl.md @@ -0,0 +1,53 @@ +# WSL2 frontend boundary + +ContainerBin's selected WSL model is a native Linux `cb` binary and native +Linux shims inside one WSL2 distribution, using Docker Desktop's supported WSL +integration. A Windows `cb.exe` launched through WSL interoperability is not the +WSL frontend, and standalone Linux remains a separate, demand-gated product. + +This first implementation slice establishes the runtime boundary only. It does +not publish a Linux artifact or enable WSL execution yet. Until the remaining +layout, namespace and Docker qualification slices land, non-bootstrap commands +fail closed on every host except native Windows. + +## Runtime classification + +- Native Windows is the currently supported frontend. +- A Windows process with `WSL_INTEROP` or `WSL_DISTRO_NAME` is classified as + Windows-through-WSL interoperability and rejected. The diagnostic names the + inherited marker so a stray variable in an otherwise native Windows process + can be found and removed. +- A native Linux process is classified as WSL2 only when + `/proc/sys/kernel/osrelease` contains both the Microsoft and WSL2 markers. + `WSL_DISTRO_NAME` is then required so later state identity cannot silently + collapse multiple distributions together. +- A Microsoft kernel without the WSL2 marker is rejected. Classic Microsoft + kernels are classified as WSL1; `microsoft-standard` kernels are reported as + ambiguous because early WSL2 releases used that form before the WSL2 suffix + became consistent. Other Linux kernels are standalone Linux and rejected. +- `cb version`, `cb help` and `cb config` remain bootstrap-safe for diagnosis; + they perform no Docker or registry mutation and return before host enforcement. + +Environment variables alone never promote an ordinary Linux kernel to WSL2. +Custom kernels that remove the Microsoft WSL2 identity markers fail closed; +Docker Desktop also documents custom WSL kernels as unsupported. + +## Required before WSL execution can be enabled + +Later reviewable slices must still implement and qualify all of the following: + +1. native config, lock, binary and symlink locations with Linux ownership and + permission checks; +2. distribution-scoped shared/project volume identities, with no implicit + equivalence to Windows paths or state; +3. native Linux path, symlink, case, stdin/TTY and signal semantics; +4. Docker Desktop WSL-integration detection without accepting a separate local + Docker Engine by accident; +5. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation + rejection; and +6. real WSL2 + Docker Desktop end-to-end qualification before any support claim. + +Docker's setup contract is documented in its +[WSL2 backend guide](https://docs.docker.com/desktop/features/wsl/): WSL2 +integration must be enabled for the selected distribution, and Docker recommends +keeping bind-mounted project files in the Linux filesystem where practical. diff --git a/exitcode_test.go b/exitcode_test.go index 53228b4..bc5d89d 100644 --- a/exitcode_test.go +++ b/exitcode_test.go @@ -58,6 +58,15 @@ func TestSubprocessExitCodes(t *testing.T) { cbPath := buildTestCb(t) workDir := t.TempDir() + if runtime.GOOS != "windows" { + unsupportedDir := filepath.Join(workDir, "unsupported-host") + if err := os.MkdirAll(unsupportedDir, 0755); err != nil { + t.Fatalf("mkdir workdir: %v", err) + } + runExitTest(t, cbPath, []string{"version"}, 0, unsupportedDir) + runExitTest(t, cbPath, []string{"doctor"}, exitCbFailure, unsupportedDir) + return + } tests := []struct { name string diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go new file mode 100644 index 0000000..01aa08e --- /dev/null +++ b/internal/hostenv/hostenv.go @@ -0,0 +1,144 @@ +// Package hostenv classifies the host process boundary before ContainerBin +// reads configuration or performs Docker/filesystem work. +package hostenv + +import ( + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" +) + +const maxKernelReleaseSize = 4 << 10 + +type Kind string + +const ( + WindowsNative Kind = "windows-native" + WindowsWSLInterop Kind = "windows-wsl-interop" + WSL2Native Kind = "wsl2-native" + WSL1Native Kind = "wsl1-native" + WSLUnrecognized Kind = "wsl-microsoft-unrecognized" + LinuxNative Kind = "linux-native" + Unsupported Kind = "unsupported" +) + +type Runtime struct { + Kind Kind + GOOS string + KernelRelease string + Distro string + InteropMarkers []string +} + +// Current returns a conservative classification of the current process. WSL2 +// must identify itself through the Microsoft WSL2 kernel release; environment +// variables alone never upgrade an ordinary Linux process into WSL support. +func Current() (Runtime, error) { + goos := runtime.GOOS + kernelRelease := "" + if goos == "linux" { + var err error + kernelRelease, err = readBoundedFile("/proc/sys/kernel/osrelease") + if err != nil { + return Runtime{}, fmt.Errorf("read Linux kernel release: %w", err) + } + } + return classify(goos, kernelRelease, os.Getenv("WSL_DISTRO_NAME"), os.Getenv("WSL_INTEROP")), nil +} + +// RequireFrontend enforces the currently shipped host boundary. Native +// Windows is supported. WSL2 is recognized explicitly but remains gated until +// its config, shim, state-namespace and Docker integration slices have landed. +func RequireFrontend() error { + return requireFrontend(Current()) +} + +func requireFrontend(info Runtime, probeErr error) error { + if probeErr != nil { + return fmt.Errorf("cannot prove a supported host runtime: %w", probeErr) + } + switch info.Kind { + case WindowsNative: + return nil + case WindowsWSLInterop: + markers := strings.Join(info.InteropMarkers, ", ") + if markers == "" { + markers = "WSL_INTEROP or WSL_DISTRO_NAME" + } + return fmt.Errorf("Windows ContainerBin process inherited WSL interoperability marker(s): %s; this invocation is unsupported; run cb from a native Windows process, or use the native WSL frontend after it is released", markers) + case WSL2Native: + if info.Distro == "" { + return errors.New("native WSL2 was detected but WSL_DISTRO_NAME is unavailable, so distribution identity cannot be proven") + } + return fmt.Errorf("native WSL2 distribution %q was detected, but the WSL frontend is not enabled in this release", info.Distro) + case WSL1Native: + return errors.New("WSL1 is unsupported; the planned native frontend requires WSL2 and Docker Desktop WSL integration") + case WSLUnrecognized: + return fmt.Errorf("Microsoft WSL kernel %q lacks an explicit WSL2 marker, so its generation cannot be proven; this host is unsupported", info.KernelRelease) + case LinuxNative: + return errors.New("standalone Linux hosts are unsupported; Linux execution is limited to the planned native WSL2 frontend") + default: + return fmt.Errorf("host operating system %q is unsupported", info.GOOS) + } +} + +func classify(goos, kernelRelease, distro, interop string) Runtime { + info := Runtime{ + GOOS: strings.TrimSpace(goos), + KernelRelease: strings.TrimSpace(kernelRelease), + Distro: strings.TrimSpace(distro), + } + interop = strings.TrimSpace(interop) + switch info.GOOS { + case "windows": + if interop != "" { + info.InteropMarkers = append(info.InteropMarkers, "WSL_INTEROP") + } + if info.Distro != "" { + info.InteropMarkers = append(info.InteropMarkers, "WSL_DISTRO_NAME") + } + if len(info.InteropMarkers) != 0 { + info.Kind = WindowsWSLInterop + } else { + info.Kind = WindowsNative + } + case "linux": + kernel := strings.ToLower(info.KernelRelease) + switch { + case strings.Contains(kernel, "microsoft") && strings.Contains(kernel, "wsl2"): + info.Kind = WSL2Native + case strings.Contains(kernel, "microsoft-standard"): + info.Kind = WSLUnrecognized + case strings.Contains(kernel, "microsoft"): + info.Kind = WSL1Native + default: + info.Kind = LinuxNative + } + default: + info.Kind = Unsupported + } + return info +} + +func readBoundedFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, maxKernelReleaseSize+1)) + if err != nil { + return "", err + } + if len(b) > maxKernelReleaseSize { + return "", fmt.Errorf("%s exceeds %d bytes", path, maxKernelReleaseSize) + } + value := strings.TrimSpace(string(b)) + if value == "" { + return "", fmt.Errorf("%s is empty", path) + } + return value, nil +} diff --git a/internal/hostenv/hostenv_test.go b/internal/hostenv/hostenv_test.go new file mode 100644 index 0000000..afa84a8 --- /dev/null +++ b/internal/hostenv/hostenv_test.go @@ -0,0 +1,96 @@ +package hostenv + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestClassify(t *testing.T) { + tests := []struct { + name, goos, kernel, distro, interop string + want Kind + }{ + {name: "windows", goos: "windows", want: WindowsNative}, + {name: "windows distro marker", goos: "windows", distro: "Ubuntu", want: WindowsWSLInterop}, + {name: "windows interop marker", goos: "windows", interop: "/run/WSL/1_interop", want: WindowsWSLInterop}, + {name: "wsl2", goos: "linux", kernel: "6.6.87.2-microsoft-standard-WSL2", distro: "Ubuntu", want: WSL2Native}, + {name: "wsl2 case insensitive", goos: "linux", kernel: "5.15.167.4-MICROSOFT-standard-wsl2", want: WSL2Native}, + {name: "legacy microsoft standard is ambiguous", goos: "linux", kernel: "4.19.128-microsoft-standard", distro: "Ubuntu", want: WSLUnrecognized}, + {name: "wsl1", goos: "linux", kernel: "4.4.0-19041-Microsoft", want: WSL1Native}, + {name: "standalone linux ignores env alone", goos: "linux", kernel: "6.12.0-generic", distro: "Ubuntu", interop: "/run/WSL/1_interop", want: LinuxNative}, + {name: "darwin", goos: "darwin", want: Unsupported}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := classify(tc.goos, tc.kernel, tc.distro, tc.interop) + if got.Kind != tc.want { + t.Fatalf("classify() kind = %q, want %q (%+v)", got.Kind, tc.want, got) + } + }) + } + windowsInterop := classify("windows", "", "Ubuntu", "/run/WSL/1_interop") + if got, want := strings.Join(windowsInterop.InteropMarkers, ","), "WSL_INTEROP,WSL_DISTRO_NAME"; got != want { + t.Fatalf("interop markers = %q, want %q", got, want) + } +} + +func TestRequireFrontend(t *testing.T) { + tests := []struct { + name string + info Runtime + probeErr error + want string + }{ + {name: "windows native", info: Runtime{Kind: WindowsNative}}, + {name: "windows interop", info: Runtime{Kind: WindowsWSLInterop, InteropMarkers: []string{"WSL_INTEROP"}}, want: "WSL_INTEROP"}, + {name: "wsl2 missing distro", info: Runtime{Kind: WSL2Native}, want: "distribution identity cannot be proven"}, + {name: "wsl2 gated", info: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, want: "WSL frontend is not enabled"}, + {name: "wsl1", info: Runtime{Kind: WSL1Native}, want: "WSL1 is unsupported"}, + {name: "unrecognized Microsoft kernel", info: Runtime{Kind: WSLUnrecognized, KernelRelease: "4.19.128-microsoft-standard"}, want: "generation cannot be proven"}, + {name: "linux", info: Runtime{Kind: LinuxNative}, want: "standalone Linux hosts are unsupported"}, + {name: "other", info: Runtime{Kind: Unsupported, GOOS: "darwin"}, want: `operating system "darwin" is unsupported`}, + {name: "probe", probeErr: errors.New("no proc"), want: "cannot prove a supported host runtime"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requireFrontend(tc.info, tc.probeErr) + if tc.want == "" { + if err != nil { + t.Fatalf("requireFrontend() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("requireFrontend() error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestReadBoundedFile(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "good") + if err := os.WriteFile(good, []byte("6.6.0-microsoft-standard-WSL2\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := readBoundedFile(good) + if err != nil || got != "6.6.0-microsoft-standard-WSL2" { + t.Fatalf("readBoundedFile() = (%q, %v)", got, err) + } + + for name, content := range map[string]string{ + "empty": " \r\n", + "oversize": strings.Repeat("x", maxKernelReleaseSize+1), + } { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + if _, err := readBoundedFile(path); err == nil { + t.Errorf("readBoundedFile(%s) succeeded", name) + } + } +} diff --git a/main.go b/main.go index 9c7d27a..32a0244 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "github.com/AviBackToBlack/container-bin/internal/cli" "github.com/AviBackToBlack/container-bin/internal/diag" "github.com/AviBackToBlack/container-bin/internal/dockerrun" + "github.com/AviBackToBlack/container-bin/internal/hostenv" "github.com/AviBackToBlack/container-bin/internal/mutationlock" "github.com/AviBackToBlack/container-bin/internal/policy" "github.com/AviBackToBlack/container-bin/internal/registry" @@ -30,6 +31,10 @@ var version = "dev" var loadRegistry = registry.Load var loadPolicy = policy.Load +// requireHostFrontend is a test seam around the fail-closed host boundary. +// Production always uses hostenv.RequireFrontend. +var requireHostFrontend = hostenv.RequireFrontend + // runSelfUpdateCheck is a test seam for proving self-update selection remains // available before policy or registry I/O. Production always uses selfupdate.Check. var runSelfUpdateCheck = selfupdate.Check @@ -39,6 +44,10 @@ func main() { if isManagementInvocation(invoked) && handleBootstrapCommand(os.Args[1:]) { return } + if err := requireHostFrontend(); err != nil { + fatalf("host runtime: %v", err) + return + } if isManagementInvocation(invoked) && len(os.Args) > 1 && os.Args[1] == "self-update" { if err := runSelfUpdateCheck(context.Background(), version, os.Args[2:], os.Stdout); err != nil { fatalf("self-update: %v", err) diff --git a/main_test.go b/main_test.go index bb04605..807a482 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "io" "os" "strings" @@ -36,19 +37,24 @@ func TestInvokedNameIsCaseInsensitive(t *testing.T) { } } -func TestBootstrapCommandsDoNotLoadRegistry(t *testing.T) { +func TestBootstrapCommandsSkipHostPolicyAndRegistry(t *testing.T) { oldArgs := os.Args oldLoadRegistry := loadRegistry + oldRequireHostFrontend := requireHostFrontend oldLoadPolicy := loadPolicy defer func() { os.Args = oldArgs loadRegistry = oldLoadRegistry + requireHostFrontend = oldRequireHostFrontend loadPolicy = oldLoadPolicy }() loadRegistry = func() (registry.Registry, string, error) { panic("bootstrap command attempted to load the registry") } + requireHostFrontend = func() error { + panic("bootstrap command attempted host enforcement") + } loadPolicy = func() (policy.Policy, error) { panic("bootstrap command attempted to load machine policy") } @@ -81,18 +87,66 @@ func TestBootstrapCommandsDoNotLoadRegistry(t *testing.T) { } } -func TestSelfUpdateCheckDoesNotLoadPolicyOrRegistry(t *testing.T) { +func TestHostBoundaryPrecedesPolicyAndRegistryLoad(t *testing.T) { + oldArgs := os.Args + oldLoadRegistry := loadRegistry + oldLoadPolicy := loadPolicy + oldRequireHostFrontend := requireHostFrontend + oldExit := osExit + defer func() { + os.Args = oldArgs + loadRegistry = oldLoadRegistry + loadPolicy = oldLoadPolicy + requireHostFrontend = oldRequireHostFrontend + osExit = oldExit + }() + + called := false + requireHostFrontend = func() error { + called = true + return errors.New("unsupported host") + } + loadRegistry = func() (registry.Registry, string, error) { + panic("host boundary attempted to load the registry") + } + loadPolicy = func() (policy.Policy, error) { + panic("host boundary attempted to load machine policy") + } + type exitCode int + osExit = func(code int) { panic(exitCode(code)) } + os.Args = []string{"cb.exe", "doctor"} + + defer func() { + got := recover() + if got != exitCode(exitCbFailure) { + t.Fatalf("main panic = %v, want exit %d", got, exitCbFailure) + } + if !called { + t.Fatal("host boundary was not called") + } + }() + main() +} + +func TestSelfUpdateCheckEnforcesHostBoundaryAndSkipsPolicyAndRegistry(t *testing.T) { oldArgs := os.Args oldLoadRegistry := loadRegistry oldLoadPolicy := loadPolicy + oldRequireHostFrontend := requireHostFrontend oldRunSelfUpdateCheck := runSelfUpdateCheck defer func() { os.Args = oldArgs loadRegistry = oldLoadRegistry loadPolicy = oldLoadPolicy + requireHostFrontend = oldRequireHostFrontend runSelfUpdateCheck = oldRunSelfUpdateCheck }() + hostChecked := false + requireHostFrontend = func() error { + hostChecked = true + return nil + } loadRegistry = func() (registry.Registry, string, error) { panic("self-update check attempted to load the registry") } @@ -115,6 +169,9 @@ func TestSelfUpdateCheckDoesNotLoadPolicyOrRegistry(t *testing.T) { if !strings.Contains(out, "self-update seam reached") { t.Fatalf("output %q does not contain self-update marker", out) } + if !hostChecked { + t.Fatal("self-update check skipped host enforcement") + } } func captureMainStdout(t *testing.T, fn func()) string {