From ea2e7ffb7fcba22974b3dfa2ae200e49af1c1773 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:08:10 +0100 Subject: [PATCH 1/2] Add proof-bound WSL container signals --- docs/wsl-process-contract.md | 9 ++++ internal/wsldocker/signal.go | 36 +++++++++++++++ internal/wsldocker/signal_linux.go | 17 +++++++ internal/wsldocker/signal_other.go | 14 ++++++ internal/wsldocker/signal_test.go | 73 ++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 internal/wsldocker/signal.go create mode 100644 internal/wsldocker/signal_linux.go create mode 100644 internal/wsldocker/signal_other.go create mode 100644 internal/wsldocker/signal_test.go diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index b8d36f6..847a33d 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -75,6 +75,15 @@ child process; they do not require Docker. ## Signals +The unexposed native Engine API path has a proof-bound container-signal +operation. It accepts one exact full container ID and one explicit numeric Linux +signal in the `1..64` domain, always supplies Docker's `signal` query parameter +and accepts only HTTP 204. It never relies on the endpoint's default `SIGKILL`. + +This primitive does not choose which host signals to intercept, install signal +handlers or define cleanup ordering. The enabled frontend must make those +policies explicit and qualify them end to end before invoking the operation. + The tool-run path installs no signal handler and creates no new process group. On native Linux/WSL, the `cb` process and its `docker` child therefore retain the operating system's default process-group relationship. ContainerBin does diff --git a/internal/wsldocker/signal.go b/internal/wsldocker/signal.go new file mode 100644 index 0000000..974ffd2 --- /dev/null +++ b/internal/wsldocker/signal.go @@ -0,0 +1,36 @@ +package wsldocker + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" +) + +const maxLinuxSignal = 64 + +func signalContainer(ctx context.Context, containerID string, signal int, deps operationDependencies) error { + if ctx == nil { + return errors.New("Docker Desktop WSL container signal requires a context") + } + if err := validateContainerID(containerID); err != nil { + return fmt.Errorf("Docker Desktop WSL container signal: %w", err) + } + if signal < 1 || signal > maxLinuxSignal { + return fmt.Errorf("Docker Desktop WSL container signal must be between 1 and %d", maxLinuxSignal) + } + if deps.check == nil || deps.statSocket == nil || deps.perform == nil { + return errors.New("Docker Desktop WSL container signal dependencies are incomplete") + } + _, err := execute(ctx, Request{ + Method: http.MethodPost, + Path: "/containers/" + containerID + "/kill", + Query: url.Values{"signal": {strconv.Itoa(signal)}}, + SuccessStatuses: []int{ + http.StatusNoContent, + }, + }, deps) + return err +} diff --git a/internal/wsldocker/signal_linux.go b/internal/wsldocker/signal_linux.go new file mode 100644 index 0000000..dde684e --- /dev/null +++ b/internal/wsldocker/signal_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package wsldocker + +import "context" + +// SignalContainer sends one explicit Linux signal number to one exact running +// container. Signal numbers outside the Linux 1..64 domain fail closed. +func SignalContainer(ctx context.Context, containerID string, signal int) error { + return signalContainer(ctx, containerID, signal, operationDependencies{ + check: Check, + statSocket: statDockerSocket, + perform: func(ctx context.Context, socketPath string, request Request) (operationResult, error) { + return performDockerRequest(ctx, socketPath, request, operationTimeout, maxOperationOutput, 0) + }, + }) +} diff --git a/internal/wsldocker/signal_other.go b/internal/wsldocker/signal_other.go new file mode 100644 index 0000000..f48ca9c --- /dev/null +++ b/internal/wsldocker/signal_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package wsldocker + +import ( + "context" + "errors" +) + +// SignalContainer is unavailable outside Linux because the fixed Unix socket +// and peer credentials are part of the Docker Desktop WSL trust boundary. +func SignalContainer(context.Context, string, int) error { + return errors.New("Docker Desktop WSL container signal requires Linux") +} diff --git a/internal/wsldocker/signal_test.go b/internal/wsldocker/signal_test.go new file mode 100644 index 0000000..2066caf --- /dev/null +++ b/internal/wsldocker/signal_test.go @@ -0,0 +1,73 @@ +package wsldocker + +import ( + "context" + "net/http" + "strconv" + "strings" + "testing" +) + +func TestSignalContainerBindsExactSignalToProvenSocket(t *testing.T) { + for _, signal := range []int{1, 2, maxLinuxSignal} { + t.Run(fmtSignal(signal), func(t *testing.T) { + socket := validSocketInfo() + statCalls := 0 + deps := validOperationDependencies(socket) + deps.statSocket = func(path string) (socketInfo, error) { + if path != DockerSocketPath { + t.Fatalf("stat path = %q", path) + } + statCalls++ + return socket, nil + } + deps.perform = func(_ context.Context, path string, request Request) (operationResult, error) { + if path != DockerSocketPath || request.Method != http.MethodPost || request.Path != "/containers/"+testContainerID+"/kill" { + t.Fatalf("perform(%q, %+v)", path, request) + } + if request.Query.Encode() != "signal="+fmtSignal(signal) || len(request.Body) != 0 || len(request.SuccessStatuses) != 1 || request.SuccessStatuses[0] != http.StatusNoContent { + t.Fatalf("signal request = %+v", request) + } + return operationResult{StatusCode: http.StatusNoContent, PeerUID: 0}, nil + } + if err := signalContainer(context.Background(), testContainerID, signal, deps); err != nil { + t.Fatal(err) + } + if statCalls != 2 { + t.Fatalf("stat calls = %d", statCalls) + } + }) + } +} + +func TestSignalContainerRejectsInvalidInputsBeforeProof(t *testing.T) { + tests := map[string]struct { + ctx context.Context + containerID string + signal int + }{ + "nil context": {containerID: testContainerID, signal: 2}, + "short ID": {ctx: context.Background(), containerID: "abc", signal: 2}, + "uppercase ID": {ctx: context.Background(), containerID: strings.ToUpper(testContainerID), signal: 2}, + "zero signal": {ctx: context.Background(), containerID: testContainerID}, + "negative": {ctx: context.Background(), containerID: testContainerID, signal: -1}, + "too large": {ctx: context.Background(), containerID: testContainerID, signal: maxLinuxSignal + 1}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + deps := validOperationDependencies(validSocketInfo()) + deps.check = func(context.Context) (Result, error) { panic("proof reached for invalid signal") } + if err := signalContainer(test.ctx, test.containerID, test.signal, deps); err == nil { + t.Fatal("signalContainer() succeeded") + } + }) + } + + if err := signalContainer(context.Background(), testContainerID, 2, operationDependencies{}); err == nil || !strings.Contains(err.Error(), "dependencies are incomplete") { + t.Fatalf("incomplete-dependencies error = %v", err) + } +} + +func fmtSignal(signal int) string { + return strconv.Itoa(signal) +} From e1bc01e0bd8e334530555c6660e29129d333bed3 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:29:19 +0100 Subject: [PATCH 2/2] Refresh WSL signal capability docs --- docs/roadmap-decisions.md | 7 ++++--- docs/roadmap-implementation-requirements.md | 12 +++++++----- docs/wsl.md | 18 +++++++++++------- internal/wsldocker/signal_test.go | 9 +++------ 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 91adb50..489ba5d 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -344,9 +344,10 @@ is not completion. preparation plus the fixed-path native install/config lifecycle are implemented; ordinary runtime integration remains gated; - Docker Desktop WSL integration proof, proof-bound bounded control requests, - and the separately constrained proof-bound attach transport are implemented - but not yet wired into an enabled frontend; multiplexed output, terminal, - resize, signal and exit-code semantics remain; + the separately constrained attach transport, strict raw-stream decoder, + exact container wait and signal operations are implemented but not yet + wired into an enabled frontend; terminal/resize, host-signal interception + and forwarding policy, and exit-code propagation remain; - namespace-prefixed/labeled WSL volume identity plus proof-bound exact inspect/create/remove and namespace discovery are implemented; tool-time creation and state/GC/backup/restore command integration remain; diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index abf9b5d..0131c22 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -79,7 +79,7 @@ The minimum delivery gate for a code change is: | Enterprise policy | **Foundation and signed registry shipped / image trust remains** | PRs #75 and #84 shipped the machine-owned constraint layer and authenticated registry; image trust remains | | Image trust | **Online production and runtime authorization implemented / offline and private-registry work remains** | Add fully pinned offline inputs and an explicit private-registry credential bridge | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two real integrations cannot fit the declarative model | -| WSL2 | **Installer foundation implemented / runtime qualification remaining** | PRs #77 and #83 shipped the fail-closed host boundary and fixed native layout/state identity; explicit read-only/apply filesystem preparation and the fixed-path native install/config/shim lifecycle are available alongside namespace-prefixed/labeled volume identity with proof-bound exact inspect/create/remove/discovery, Docker Desktop integration proof, bounded control requests, constrained attach and exact context-bound wait transports, while command/frontend wiring, stream/terminal/signal semantics and real WSL qualification remain | +| WSL2 | **Installer foundation implemented / runtime qualification remaining** | PRs #77 and #83 shipped the fail-closed host boundary and fixed native layout/state identity; explicit read-only/apply filesystem preparation and the fixed-path native install/config/shim lifecycle are available alongside namespace-prefixed/labeled volume identity with proof-bound exact inspect/create/remove/discovery, Docker Desktop integration proof, bounded control requests, constrained attach, strict raw-stream decoding, exact context-bound wait and proof-bound container-signal operations, while command/frontend wiring, terminal/resize, host-signal interception/forwarding policy, exit semantics and real WSL qualification remain | | Per-project overlays | **Completed in PR #80** | Add-only digest-bound trust model shipped on the merged enterprise-policy foundation | | Release SBOM | **Conditionally deferred** | Trigger on shipped third-party/runtime dependencies or concrete compliance/consumer demand | | Snyk | **Conditionally deferred** | Trigger only for a real coverage gap plus owner/account/token and triage/outage policy | @@ -607,10 +607,12 @@ registry-derived management/tool-shim reconciliation without enabling tool execution. Canonical project storage classification with its proof-consuming argument mapper, fail-closed Docker Desktop WSL integration proof and a proof-bound bounded Engine API control-request primitive, constrained attach -transport and exact context-bound container-wait operation are implemented but -not yet wired into an enabled frontend. Multiplexed-output decoding, -terminal/resize, signal and end-to-end exit-code propagation remain. Runtime -wiring, argument/process behavior and real WSL qualification remain. +transport, strict multiplexed-output decoder, exact context-bound +container-wait operation and proof-bound container-signal operation are +implemented but not yet wired into an enabled frontend. Terminal/resize, +host-signal interception and forwarding policy, and end-to-end exit-code +propagation remain. Runtime wiring, argument/process behavior and real WSL +qualification remain. Implementation must define native config/shim location, Docker endpoint, project identity, named-volume behavior, file permissions, case sensitivity, diff --git a/docs/wsl.md b/docs/wsl.md index 015fee1..4655db9 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -237,10 +237,14 @@ accepts only an exact full container ID and fixes the request to `condition=not-running`. It repeats the socket/peer proof, uses the caller's context as the long-poll lifetime, bounds the response to 64 KiB, rejects an unsafe Engine error, and accepts only process exit codes from 0 through 255. -These primitives do not decode multiplexed output or implement container -creation/start, terminal behavior, resize, signals, or end-to-end exit-code -propagation. Nothing is wired into tool execution yet; real WSL2 + Docker -Desktop qualification remains mandatory before support. +A separate proof-bound signal operation accepts only an exact full container ID +and an explicit numeric Linux signal in the `1..64` domain, always supplies the +Engine `signal` query and accepts only HTTP 204. The native package also has a +strict decoder for non-TTY multiplexed output. These primitives do not implement +container creation/start, terminal behavior/resize, host-signal interception or +forwarding policy, or end-to-end exit-code propagation. Nothing is wired into +tool execution yet; real WSL2 + Docker Desktop qualification remains mandatory +before support. ## Native WSL volume identity and control lifecycle @@ -283,9 +287,9 @@ following: mapper into native tool execution, then complete stdin/TTY and signal semantics; 3. wire the implemented bounded Docker Desktop control-operation and attach - primitives, raw-stream decoder and wait primitive into container lifecycle, - then implement terminal/resize, signal and exit-code propagation without - accepting ambient + primitives, raw-stream decoder, wait and signal operations into container + lifecycle, then implement terminal/resize, host-signal interception and + forwarding policy, and exit-code propagation without accepting ambient endpoint overrides; 4. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation rejection; and diff --git a/internal/wsldocker/signal_test.go b/internal/wsldocker/signal_test.go index 2066caf..eda62cc 100644 --- a/internal/wsldocker/signal_test.go +++ b/internal/wsldocker/signal_test.go @@ -10,7 +10,7 @@ import ( func TestSignalContainerBindsExactSignalToProvenSocket(t *testing.T) { for _, signal := range []int{1, 2, maxLinuxSignal} { - t.Run(fmtSignal(signal), func(t *testing.T) { + t.Run(strconv.Itoa(signal), func(t *testing.T) { socket := validSocketInfo() statCalls := 0 deps := validOperationDependencies(socket) @@ -25,7 +25,7 @@ func TestSignalContainerBindsExactSignalToProvenSocket(t *testing.T) { if path != DockerSocketPath || request.Method != http.MethodPost || request.Path != "/containers/"+testContainerID+"/kill" { t.Fatalf("perform(%q, %+v)", path, request) } - if request.Query.Encode() != "signal="+fmtSignal(signal) || len(request.Body) != 0 || len(request.SuccessStatuses) != 1 || request.SuccessStatuses[0] != http.StatusNoContent { + if request.Query.Encode() != "signal="+strconv.Itoa(signal) || len(request.Body) != 0 || len(request.SuccessStatuses) != 1 || request.SuccessStatuses[0] != http.StatusNoContent { t.Fatalf("signal request = %+v", request) } return operationResult{StatusCode: http.StatusNoContent, PeerUID: 0}, nil @@ -49,6 +49,7 @@ func TestSignalContainerRejectsInvalidInputsBeforeProof(t *testing.T) { "nil context": {containerID: testContainerID, signal: 2}, "short ID": {ctx: context.Background(), containerID: "abc", signal: 2}, "uppercase ID": {ctx: context.Background(), containerID: strings.ToUpper(testContainerID), signal: 2}, + "non-hex ID": {ctx: context.Background(), containerID: strings.Repeat("g", 64), signal: 2}, "zero signal": {ctx: context.Background(), containerID: testContainerID}, "negative": {ctx: context.Background(), containerID: testContainerID, signal: -1}, "too large": {ctx: context.Background(), containerID: testContainerID, signal: maxLinuxSignal + 1}, @@ -67,7 +68,3 @@ func TestSignalContainerRejectsInvalidInputsBeforeProof(t *testing.T) { t.Fatalf("incomplete-dependencies error = %v", err) } } - -func fmtSignal(signal int) string { - return strconv.Itoa(signal) -}