diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index 2a4c6feb3..ec3684131 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -196,9 +196,9 @@ jobs: # Exercises the real binary against the real dependencies: launches the # slirp4netns supervisor, joins its user namespace, and asserts the - # sandbox lands in a private network namespace. Needs unix-test-proxy - # (builtinTestServer) alongside lxc-exec, which the two build steps - # above place in the same target directory. + # sandbox lands in a private network namespace. The test script starts + # unix-test-proxy independently alongside lxc-exec; the two build steps + # above place them in the same target directory. - name: Test Bubblewrap proxy networking (end-to-end) working-directory: ${{ github.workspace }} env: diff --git a/build-mac.sh b/build-mac.sh index 88ddec784..8f738562e 100755 --- a/build-mac.sh +++ b/build-mac.sh @@ -96,9 +96,8 @@ echo "" echo "=== Building mxc-exec-mac ($BUILD_TYPE) ===" cd "$SRC_DIR" -# mxc-exec-mac is the seatbelt executor. unix-test-proxy is the bundled, -# testing-only HTTP proxy that backs `network.proxy.builtinTestServer`; it is -# spawned as a sibling of mxc-exec-mac by the proxy coordinator. +# mxc-exec-mac is the Seatbelt executor. Keep unix-test-proxy as an +# externally managed test endpoint for integration probes. CARGO_FLAGS=("-p" "mxc_darwin" "-p" "unix_test_proxy" "-p" "mxc_ffi") if [ "$BUILD_TYPE" = "release" ]; then CARGO_FLAGS+=("--release") @@ -141,8 +140,7 @@ copy_binary_for_target() { echo "Warning: $ffi_src not found, skipping copy" fi - # unix-test-proxy backs network.proxy.builtinTestServer (testing only). - # It must sit next to mxc-exec-mac so the proxy coordinator can resolve it. + # Retain the test proxy binary for callers that start it independently. local proxy_src="$SRC_DIR/target/$triple/$BUILD_TYPE/unix-test-proxy" if [ -f "$proxy_src" ]; then cp "$proxy_src" "$bin_dir/unix-test-proxy" diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 93759cc3f..c1d324d33 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -14,12 +14,12 @@ requiring root privileges or a container runtime. > Versions before `0.9.0-alpha` are rejected; changing only the version of an > old config does not migrate its policy. Legacy `defaultPolicy`, > `enforcementMode`, host lists, `allowLocalNetwork`, and `network.proxy` -> are not accepted. See [schema migration](../schema.md). -> Directly constructed runtime requests with non-default retired fields are -> also rejected before sandbox provisioning. Bubblewrap also rejects -> directional requests it cannot enforce, such as `ingress.default: "allow"` -> or direct egress rules combined with a runtime proxy. An omitted `network` -> section takes the directional deny defaults. +> are rejected by supported exact contracts. Typed Rust requests no longer +> contain these fields. See [schema migration](../schema.md). +> Bubblewrap still rejects enforceable-looking requests it cannot honor +> (such as `ingress.default: "allow"` or direct egress rules combined with a +> runtime proxy) before provisioning. An omitted `network` section takes the +> directional deny defaults. ## Prerequisites @@ -382,8 +382,8 @@ Be honest about what this buys. It is **not** new protection: nothing outside the sandbox can reach in already, because the runner configures no port forwarding into the namespace, so there is no path for an inbound packet to arrive on. The chain is defense in depth against a future change that adds -one, and the mechanism the GA networking spec expects a backend to apply -`ingress.default` through. The terminal `DROP` is deliberately independent of +one, and a defense-in-depth implementation of `ingress.default`. The terminal +`DROP` is deliberately independent of `egress.default`, which governs outbound traffic only — an open outbound posture must not open inbound as a side effect. diff --git a/docs/linux-wsl-roadmap-june-2026.md b/docs/linux-wsl-roadmap-june-2026.md index 1fc738cda..7aeafdc2b 100644 --- a/docs/linux-wsl-roadmap-june-2026.md +++ b/docs/linux-wsl-roadmap-june-2026.md @@ -4,6 +4,9 @@ Forward-looking work items for the three Linux-side containment backends: **LXC**, **Bubblewrap**, and **WSLC**. +Supported exact contracts start at `0.9.0-alpha`. Use the backend guides for +current enforcement behavior. + Each item is prioritized within its backend and tagged with an effort tier. **Effort tiers:** @@ -14,7 +17,7 @@ Each item is prioritized within its backend and tagged with an effort tier. **Filesystem policy reference:** items tagged with **(D1)**–**(D8)** trace to the [MXC FS-policy semantics v1](https://github.com/microsoft/mxc/blob/user/gudge/downlevel-fs-projection-plan/docs/proposals/downlevel_support/policy_semantics_v1_summary.md) decisions. Items shared across backends note where the implementation lives (typically `mxc_common`). -**Network policy reference:** items tagged with **(N1)**–**(N8)** trace to the [MXC Network Configuration GA spec](https://microsoft-my.sharepoint-df.com/:w:/p/bbonaby/cQpR4CPfeKqgSLuQGG_a9QA2EgUCrPdXr5J7b-jWip1_VeYFUA) design decisions. The GA schema replaces the current `allowedHosts`/`blockedHosts`/`defaultPolicy` format: +**Network policy reference:** items tagged with **(N1)**–**(N8)** trace to the [MXC Network Configuration GA spec](https://microsoft-my.sharepoint-df.com/:w:/p/bbonaby/cQpR4CPfeKqgSLuQGG_a9QA2EgUCrPdXr5J7b-jWip1_VeYFUA) design decisions. Supported contracts use the directional shape rather than the retired host-list format: ```json { @@ -88,7 +91,7 @@ File:line citations reference paths under `src/backends//...` and `src/ | # | Item | Status | Description | Effort | |---|---|---|---|---| | 13 | **(N1) Default-deny outbound** | 🟡 Actionable | Already in place: iptables FORWARD hook with default DROP when firewall mode + veth detected. New work: ensure hook is always applied; fail-fast if veth not found rather than silently skipping. | M | -| 14 | **(N2) Host-loopback control (`hostLoopback`)** | 🟠 Runtime API dependency | `allowLocalNetwork` is parsed but silently ignored. A private network namespace makes sandbox `127.0.0.1`/`::1` different from host loopback, so both directions need explicit cross-namespace plumbing. Container-to-host requires a host-loopback relay or translated gateway endpoint plus OUTPUT enforcement. Host-to-container requires a host-loopback-bound relay or DNAT/forward and an INPUT chain that allows `NEW` only for that forwarded path while dropping direct veth/LAN ingress. The shared allow/deny policy does not identify listener ports, so a separate runtime port-mapping contract is required; `hostLoopback: "allow"` authorizes mappings but cannot create them by itself. Until that contract and dual-stack `iptables`/`ip6tables` or `nftables` enforcement exist, reject `hostLoopback: "allow"` rather than guessing ports or exposing the container IP. `-i lo` remains intra-container only, and `ESTABLISHED,RELATED` remains allowed. Depends on the IPv6 path in item #19. | L | +| 14 | **(N2) Host-loopback control (`hostLoopback`)** | 🟠 Runtime API dependency | LXC rejects `hostLoopback: "allow"`. A private network namespace makes sandbox `127.0.0.1`/`::1` different from host loopback, so both directions need explicit cross-namespace plumbing. Container-to-host requires a host-loopback relay or translated gateway endpoint plus OUTPUT enforcement. Host-to-container requires a host-loopback-bound relay or DNAT/forward and an INPUT chain that allows `NEW` only for that forwarded path while dropping direct veth/LAN ingress. The shared allow/deny policy does not identify listener ports, so a separate runtime port-mapping contract is required; `hostLoopback: "allow"` authorizes mappings but cannot create them by itself. Until that contract and dual-stack `iptables`/`ip6tables` or `nftables` enforcement exist, reject `hostLoopback: "allow"` rather than guessing ports or exposing the container IP. `-i lo` remains intra-container only, and `ESTABLISHED,RELATED` remains allowed. Depends on the IPv6 path in item #19. | L | > **Example (N2).** With `ingress.hostLoopback: "deny"` (default), the host cannot reach an MCP server in the container > and the container cannot reach a service on host loopback. With `"allow"`, both directions are authorized, but the @@ -97,13 +100,11 @@ File:line citations reference paths under `src/backends//...` and `src/ | # | Item | Status | Description | Effort | |---|---|---|---|---| -| 15 | **(N3) IP/CIDR only, no DNS names** | ✅ Addressed | Schema 0.8 `network.egress` is lowered into the chain with CIDR peers, `except` carve-outs, ports, and protocols. IPv4 and IPv6 peers are routed to the `iptables` and `ip6tables` chains by address family, which closes the dual-stack bypass for this shape. The legacy `allowedHosts`/`blockedHosts` path is unchanged and still resolves names. Covered end-to-end by `tests/scripts/run_lxc_network_ga_egress_test.sh`. | L | - -> **Example (N3).** Today: `allowedHosts: ["api.github.com"]` resolves once to `140.82.112.4`. On a dual-stack host, IPv6 `2606:50c0:8000::64` passes unfiltered. GA: `egress.allow: [{ to: [{ cidr: "140.82.112.0/20" }], ports: [{ protocol: "tcp", port: 443 }] }]` — deterministic, auditable, covers the subnet. +| 15 | **(N3) IP/CIDR only, no DNS names** | ✅ Addressed | Supported `network.egress` is lowered into the chain with CIDR peers, `except` carve-outs, ports, and protocols. IPv4 and IPv6 peers are routed to the `iptables` and `ip6tables` chains by address family. Covered end-to-end by `tests/scripts/run_lxc_network_ga_egress_test.sh`. | L | | # | Item | Status | Description | Effort | |---|---|---|---|---| -| 16 | **(N4) Deny-wins precedence** | ✅ Addressed | `egress.deny[]` rules are emitted ahead of `egress.allow[]` rules, matching the ordering already used for the legacy host lists. The legacy DNS exemption is not carried into a directional posture — port 53 is governed by the same rules as every other forwarded destination, per GA decision D3. Two paths still sit outside the generated rules: the base chain's `ESTABLISHED,RELATED` accept, and the bridge resolver, which the container reaches through the host's `INPUT` path rather than this chain. Both are stated in `docs/lxc-support/lxc-backend.md`. | S | +| 16 | **(N4) Deny-wins precedence** | ✅ Addressed | `egress.deny[]` rules are emitted ahead of `egress.allow[]` rules. Port 53 follows the same rules as every other forwarded destination, per GA decision D3. Two paths still sit outside the generated rules: the base chain's `ESTABLISHED,RELATED` accept, and the bridge resolver, which the container reaches through the host's `INPUT` path rather than this chain. Both are stated in `docs/lxc-support/lxc-backend.md`. | S | | 17 | **(N5) Proxy — env vars + enforcement** | 🟡 Actionable | Schema field exists, backend ignores it. Fix: inject `HTTP_PROXY`/`HTTPS_PROXY`, clear all inherited proxy vars, and restrict egress to proxy port only via iptables. | M | > **Example (N5).** Consumer starts proxy on `127.0.0.1:8080`. MXC sets `HTTP_PROXY=127.0.0.1:8080` inside the container and applies `iptables -A OUTPUT -d 127.0.0.1 --dport 8080 -j ACCEPT` + default DROP. An app ignoring the env var tries `connect(140.82.112.4:443)` → dropped. @@ -173,11 +174,9 @@ File:line citations reference paths under `src/backends//...` and `src/ | # | Item | Status | Description | Effort | |---|---|---|---|---| -| 13 | **(N1) Default-deny outbound** | 🟡 Actionable | Already in place: `--unshare-net` provides full cutoff when no proxy/rules. New work: with proxy active (currently shares host netns), switch to `--unshare-net` + route proxy into the namespace (slirp4netns or veth pair). Elevation required. | M | -| 14 | **(N2) Host-loopback control (`hostLoopback`)** | 🟠 Runtime API dependency | `--unshare-net` blocks both host-loopback directions by default because sandbox loopback is private. N1 must first move proxy mode off the shared host namespace. After that, container-to-host requires a host-loopback relay or translated gateway endpoint plus OUTPUT enforcement; host-to-container requires a host-loopback-bound relay or DNAT/forward plus per-sandbox INPUT filtering that allows `NEW` only for mapped listeners and drops direct veth/LAN ingress. The shared allow/deny policy does not identify listener ports, so a separate runtime port-mapping contract is required; `hostLoopback: "allow"` authorizes mappings but cannot create them by itself. Until that contract, N1, and dual-stack `iptables`/`ip6tables` or `nftables` enforcement exist, reject `hostLoopback: "allow"` rather than retaining the shared host namespace, guessing ports, or exposing the container IP. `-i lo` remains intra-container only, and `ESTABLISHED,RELATED` remains allowed. Depends on item #19. | L | -| 15 | **(N3) IP/CIDR only, no DNS names** | 🟡 Actionable | Delegates to LXC's `NetworkIptablesManager` — same IPv4-only hostname resolution, same dual-stack bypass. New GA schema needed. | L | - -> **Example (N3).** Same IPv6 bypass as LXC: `allowedHosts: ["api.github.com"]` only blocks IPv4; IPv6 traffic passes unfiltered on dual-stack GHA runners (confirmed by probe). +| 13 | **(N1) Default-deny outbound** | ✅ Addressed | Ruleless deny isolates with `--unshare-net`; proxy-only and rule-bearing directional policies use a slirp-backed private namespace with an enforced egress chain. | M | +| 14 | **(N2) Host-loopback control (`hostLoopback`)** | 🟠 Runtime API dependency | The private namespace denies host loopback by default. `hostLoopback: "allow"` remains rejected: admitting the host-to-container half requires an explicit port-mapping contract and an inbound relay/filter, while the container-to-host half needs gateway reachability and OUTPUT enforcement. No listener ports may be guessed or opened implicitly. | L | +| 15 | **(N3) IP/CIDR only, no DNS names** | ✅ Addressed | Bubblewrap lowers supported numeric CIDR, `except`, protocol, and port rules to its private namespace's IPv4/IPv6 chains. The directional host suite exercises filtering and deny precedence. | L | | # | Item | Status | Description | Effort | |---|---|---|---|---| @@ -280,7 +279,7 @@ File:line citations reference paths under `src/backends//...` and `src/ > > The container gets no network interface, so all outbound is denied. Genuine default-deny — but the blunt form, with *zero* connectivity. Use when the workload needs no network at all. > -> **⚠️ Needs to change — deny + allowlist.** An `allow` list → maps to `Bridged` (full NAT), then MXC tries to enforce the list with `iptables` exec'd *inside* the container (`build_iptables_rules`): +> **⚠️ Needs the VM-level API — deny + allowlist.** WSLC rejects a directional `allow` list before provisioning; it no longer attempts in-container iptables enforcement: > > ```json > { @@ -295,7 +294,7 @@ File:line citations reference paths under `src/backends//...` and `src/ > } > ``` > -> Intended: reach **only** `140.82.112.0/20:443`. Actual: the in-container `iptables` calls fail silently because `WslcContainerFlags::Privileged` does **not** grant `CAP_NET_ADMIN`, leaving the container on full Bridged NAT with no firewall — it reaches the allowed host *and everything else*. Closing this needs the VM-level network policy API (SDK dep #1) to enforce default-DROP + allowlist at the VM host. +> Intended: reach **only** `140.82.112.0/20:443`. Actual: the request is refused rather than broadening access. Enforcing it needs the VM-level network policy API (SDK dep #1) to apply default-DROP + allowlist at the VM host. | # | Item | Status | Description | Effort | |---|---|---|---|---| @@ -304,7 +303,7 @@ File:line citations reference paths under `src/backends//...` and `src/ > **Example (N2).** `ingress.hostLoopback` is bidirectional. The port-mapping support below covers the > host-to-container half. Container-to-host-loopback access also requires VM-level routing and policy support. > -> **✅ Supported today — explicit per-port forward.** The container runs in the NAT'd WSL2 VM, so by default the host can't reach arbitrary container ports (incidental default-deny). [PR #530](https://github.com/microsoft/mxc/pull/530) adds the per-port primitive via `WslcSetContainerSettingsPortMappings` (`wsl_container_runner.rs:975+`) — an explicit `hostLoopback: "allow"` for one TCP port: +> **✅ Supported today — explicit per-port forward.** The container runs in the NAT'd WSL2 VM, so by default the host can't reach arbitrary container ports (incidental default-deny). [PR #530](https://github.com/microsoft/mxc/pull/530) added the separate `WslcSetContainerSettingsPortMappings` per-port primitive; this does not authorize directional `hostLoopback: "allow"`: > > ```json > { @@ -319,11 +318,11 @@ File:line citations reference paths under `src/backends//...` and `src/ > > This forwards host port `3000` → container `:3000`. **Host bind address:** MXC does not supply one today — the runner passes `windows_address: null` (`wsl_container_runner.rs:1044-1046`), which delegates to the WSLC SDK's default host bind (`wslc_bindings.rs:242-244`). That default is **not guaranteed to be loopback-only** and may expose a broader host interface set (e.g. `0.0.0.0`), so this is not a verified `127.0.0.1`-only forward until MXC passes an explicit loopback address. TCP only — UDP is rejected at parse time because the shipped WSLC runtime returns `E_NOTIMPL` for UDP port mappings. > -> **⚠️ Needs to change — policy-driven posture.** `allowLocalNetwork: true` is now **rejected at config-parse time** (`config_parser.rs`) and by the WSLc backend's `validate_runner`, because MXC cannot honor a blanket inbound-listen grant inside the NAT'd WSL2 VM; `allowLocalNetwork: false` remains an accepted no-op. Only the imperative `portMappings` list exposes inbound ports today. There is no way to express a blanket `hostLoopback: "allow"` default (host-loopback to every exposed port) or source-scoped inbound filtering (allow `127.0.0.1`/`::1` only, deny other host interfaces). Wiring a policy-driven inbound posture needs the VM-level network policy API (SDK dep #1), since MXC has no host-side access to the container's interface inside the VM. +> **⚠️ Needs to change — policy-driven posture.** Directional `ingress.default: "allow"` and `hostLoopback: "allow"` are rejected by WSLC before provisioning because MXC cannot honor a blanket inbound-listen grant inside the NAT'd WSL2 VM. Only imperative `portMappings` expose inbound ports today. Source-scoped inbound filtering (allow `127.0.0.1`/`::1` only, deny other host interfaces) requires the VM-level network policy API (SDK dep #1). | # | Item | Status | Description | Effort | |---|---|---|---|---| -| 17 | **(N3) IP/CIDR allow/deny rules** | 🟠 With SDK dep | Currently builds iptables rules inside container (requires `CAP_NET_ADMIN` which isn't granted). VM-level API would accept CIDR rules directly. | M | +| 17 | **(N3) IP/CIDR allow/deny rules** | 🟠 With SDK dep | Directional rules are rejected before provisioning. VM-level API would accept CIDR rules directly. | M | > **Example (N3).** N3 is the per-host egress filtering — *which* destinations are allowed/blocked. Target GA shape: > @@ -340,13 +339,13 @@ File:line citations reference paths under `src/backends//...` and `src/ > } > ``` > -> **⚠️ Rejected before execution today.** Per-host egress filtering is **rejected up front** — at config-parse time (`config_parser.rs`) and by the WSLc backend's `validate_runner` (`wsl_container_runner.rs`), so no container is ever created. It can't be enforced for **two independent reasons**: (1) the in-container iptables path (`build_iptables_rules` → `apply_iptables_rules`, exec'd via `WslcCreateContainerProcess`) needs `CAP_NET_ADMIN`, which the `Privileged` flag does **not** grant inside the container, so `iptables -A` is rejected; and (2) WSLc cannot expose **VM-level** network enforcement without breaking other security promises (e.g. MDE) — confirmed with the WSLc SDK team — so there is no host-side place to enforce it either, pending a longer-tail design. That rule-building logic is retained for a future privileged / VM-level design but is currently unreachable. Either way the config **fails before the run**, never failing open. +> **⚠️ Rejected before execution today.** Supported contracts can express CIDR rules, but the WSLc backend rejects them before provisioning. MXC does not grant `CAP_NET_ADMIN` to workloads. VM-level enforcement is not available without breaking other security promises (e.g. MDE), so there is still no host-side primitive to apply the rules. The request fails before the run, never failing open. > -> **✅ Needs the VM-level API.** Move enforcement off in-container iptables entirely. With the VM-level network policy API (SDK dep #1), MXC passes the rule set at `CreateSession` and the VM host enforces it — no container privilege, no image iptables dependency. Today's rules also match only a bare `-d ` (whole host, all ports/protocols); CIDR ranges, `--dport`, `-p tcp/udp/icmp`, and hostname rejection are the separate #22/#23/#24 rows, all on the same SDK dependency. +> **✅ Needs the VM-level API.** With the VM-level network policy API (SDK dep #1), MXC could pass the rule set at `CreateSession` for enforcement at the VM host — no container privilege or image iptables dependency. Future lowering must handle CIDRs, ports, protocols, and both IP families. | # | Item | Status | Description | Effort | |---|---|---|---|---| -| 18 | **(N4) Deny-wins precedence** | 🟠 With SDK dep | No `egress.deny[]` path today — the builder does allow-list XOR block-list, never both, so deny-wins ordering isn't expressed. VM-level API + N7 schema needed. | S | +| 18 | **(N4) Deny-wins precedence** | 🟠 With SDK dep | The schema expresses `egress.deny[]`, but WSLC rejects direct rules until a VM-level enforcement API can apply deny-before-allow ordering. | S | > **Example (N4).** GA spec D4: when a connection matches both an `egress.allow` and an `egress.deny` rule, **the > deny wins** (fail-closed). The canonical case is "allow everything except a few malicious IPs." These rules apply @@ -368,19 +367,20 @@ File:line citations reference paths under `src/backends//...` and `src/ > } > ``` > -> **❌ Not expressible today.** `build_iptables_rules` (`policy_mapping.rs:183-221`) handles only two shapes — `defaultPolicy: block` + `allowedHosts` (allow-list with trailing DROP) or `defaultPolicy: allow` + `blockedHosts` (block-list) — and **never combines** an allow-list and a deny-list in one chain. So the D4 scenario (broad allow + specific deny) has no representation: the allow side is simply ignored in the block-list branch, and there's no rule interleaving to give deny precedence. On top of that, whatever it does build doesn't enforce and is rejected before the run — no `CAP_NET_ADMIN` for in-container iptables and no VM-level enforcement hook, same as N3). +> **❌ Expressible but unenforceable on WSLC today.** Supported contracts carry both `egress.allow` and `egress.deny`; WSLC refuses either rule set before provisioning. Future VM-level lowering must enforce deny-before-allow ordering. > -> **✅ Needs the VM-level API + N7 schema.** Two changes: (1) model `egress.allow[]` and `egress.deny[]` together and guarantee deny-rules are evaluated before allows (the N7 migration plus a rule-ordering change), and (2) enforce at the VM host via the VM-level network policy API (SDK dep #1) rather than in-container iptables. +> **✅ Needs the VM-level API.** The schema already models `egress.allow[]` and `egress.deny[]` together. The missing step is VM-host enforcement with deny precedence (SDK dep #1). | # | Item | Status | Description | Effort | |---|---|---|---|---| -| 19 | **(N5) Proxy — env-var injection** | 🟡 Actionable NOW | Set `HTTP_PROXY`/`HTTPS_PROXY` via `WslcCreateContainerProcess` env parameter. No SDK dependency. | S | +| 19 | **(N5) Proxy — env-var injection** | ✅ Addressed | Set `HTTP_PROXY`/`HTTPS_PROXY` from the caller-managed runtime proxy when starting a WSLc process. | S | | 20 | **(N5) Proxy — egress enforcement** | 🟠 With SDK dep | Restricting egress to proxy port only requires VM-level network policy API. Without it, proxy is advisory (apps can bypass env vars and connect directly). | M | -| 25 | **(N5) Proxy — env-var hygiene** | 🟡 Actionable NOW | Clear all proxy vars, set only configured proxy. No SDK dependency — env manipulation at process spawn. | S | +| 25 | **(N5) Proxy — env-var hygiene** | ✅ Addressed | Scrub caller-supplied proxy variables before injecting the configured proxy at process spawn. | S | -> **Example (N5).** The proxy is the **recommended GA path** (model 2, "deny-all-except-proxy"). MXC does **not** -> run the proxy: the consumer supplies and starts it, while MXC restricts egress to it and sets the proxy environment -> variables. The variables are an advisory routing hint; the VM-level network policy is the containment boundary. +> **Example (N5).** The caller starts a proxy and supplies its URL. WSLC injects +> the proxy environment variables but cannot restrict direct egress to the +> endpoint; without VM-level network policy, this is cooperative routing, not +> a proxy-only containment boundary. > Direct `egress.allow`/`deny` rules do not apply when `runtimeConfig.networkProxy` is present. > > ```json @@ -398,30 +398,35 @@ File:line citations reference paths under `src/backends//...` and `src/ > } > ``` > -> The presence of `runtimeConfig.networkProxy` selects the proxy-only runtime path; there is no caller-selected mode. -> `ingress.hostLoopback` remains denied because the container-to-proxy connection is outbound. WSLC must translate the -> caller's local endpoint to a VM-reachable address and authorize only that outbound endpoint. +> `runtimeConfig.networkProxy` supplies execution-time routing metadata; +> `ingress.hostLoopback` remains denied. A guest-local proxy can be reached at +> the container's own `127.0.0.1`; a host-side proxy instead needs a +> guest-reachable URL and cannot rely on the host's loopback address. > -> **❌ Not implemented today (but #19/#25 are unblocked).** WSLC has no proxy code at all. The env path exists — `request.env` is piped in via `WslcSetProcessSettingsEnvVariables` (`wsl_container_runner.rs:929-942`) — but nothing injects `HTTP_PROXY`/`HTTPS_PROXY` from the proxy config (#19), and the GA-mandated clearing of all inherited proxy vars (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `FTP_PROXY`, `NO_PROXY` + lowercase) isn't done (#25). Both are doable now through the existing env path — no SDK dependency — they're just unwritten. +> **✅ Routing and hygiene implemented (#19/#25).** WSLC injects the configured +> proxy URL and scrubs caller-supplied proxy variables, but raw-socket clients +> can still bypass this cooperative routing. > -> **❌ Enforcement blocked (#20).** The part that *matters* per the GA spec — the iptables rule that restricts egress to only the loopback proxy port and DROPs everything else — can't be done: same dead end as N1/N3 (in-container iptables, `Privileged` ≠ `CAP_NET_ADMIN`). Without it the proxy is **advisory only**, which the GA doc says is insufficient as the enforcement mechanism. Needs the VM-level network policy API (SDK dep #1). +> **❌ Enforcement blocked (#20).** WSLC cannot restrict direct egress to the +> proxy endpoint without the VM-level network policy API (SDK dep #1). > -> **⚠️ WSLC-specific wrinkle — NAT reachability.** Unlike Bubblewrap (which shares the host's network namespace, so the container's `127.0.0.1` *is* the host's), the WSLC container runs in the WSL2 VM — a separate kernel with its **own** loopback, behind a NAT. `127.0.0.1` is always machine-local and never routed, so `HTTP_PROXY=127.0.0.1:8080` points at the *container's own* empty loopback, not the host where the proxy listens. The connection fails — the proxy is unreachable, so model 2 is broken outright (not merely advisory). Fixing it means **not** using loopback: MXC must inject the host's VM-visible gateway IP (e.g. the address WSL puts in `/etc/resolv.conf`) instead, and the consumer's proxy must bind on a VM-reachable interface. This is the backend-specific "making the proxy reachable from inside the sandbox" the GA spec assigns to MXC, and it's a prerequisite for the env var (#19) to be of any use. +> **⚠️ WSLC-specific wrinkle — NAT reachability.** WSLC has its own network +> namespace; `127.0.0.1` names the guest's own loopback and is valid for a +> guest-local proxy. It does not reach a proxy listening only on the Windows +> host's loopback. A host-side proxy must bind to an address the guest can +> reach and be supplied through a guest-routable URL. > > **Net:** shipping #19 + #25 alone yields a *cooperative-only* proxy a rogue app bypasses; the GA-meaningful guarantee (unbypassable model 2) needs #20 (SDK-blocked) plus the NAT-reachability plumbing. | # | Item | Status | Description | Effort | |---|---|---|---|---| -| 21 | **(N7) Schema migration** | 🟡 Actionable NOW | Same parser + SDK types as LXC/Bwrap. No SDK dependency for schema/parser work. | L | +| 21 | **(N7) Schema migration** | ✅ Addressed | Supported exact contracts and SDK types use the shared directional fields and runtime proxy; backend enforcement remains a separate dependency. | L | -> **Example (N7).** N7 is the schema/parser/SDK work to accept the GA network block — *expressing* the policy, independent of whether a backend can *enforce* it. It's the same shared parser + SDK types as LXC/Bwrap, so no WSLC SDK dependency. -> -> **⚠️ Today — flat legacy schema only.** The parser accepts only -> `defaultPolicy`/`allowedHosts`/`blockedHosts` (`config_parser.rs:778-779`, flat string lists), mapped to -> `policy.allowed_hosts`/`blocked_hosts`. There is no schema 0.8 `egress`/`ingress` structure or per-rule -> `to[].cidr` + `ports[]`, and no `runtimeConfig.networkProxy`. +> **Example (N7).** Supported contracts and SDK types already express the directional network block, independently of whether WSLC can enforce a requested rule. > -> **✅ GA target.** Parse the structured GA block (shared across all backends), with deprecation aliases from the legacy fields: +> **✅ Current shape.** Exact v0.9+ contracts accept `network.egress`, +> `network.ingress`, and `runtimeConfig.networkProxy`. Retired host-list fields +> have no aliases in supported contracts: > > ```json > { @@ -441,7 +446,7 @@ File:line citations reference paths under `src/backends//...` and `src/ > } > ``` > -> This is pure schema/parser/SDK work — landing it lets configs *express* CIDR/port/protocol intent. Whether WSLC can *enforce* that intent is the separate #22–#24 + VM-level API story below. +> The parser and SDKs express CIDR/port/protocol intent; WSLC rejects unsupported enforcement before provisioning. VM-level policy support is still required for #22–#24 below. | # | Item | Status | Description | Effort | |---|---|---|---|---| @@ -449,7 +454,7 @@ File:line citations reference paths under `src/backends//...` and `src/ | 23 | **Port filtering** | 🟠 With SDK dep | VM-level API would accept port/port-range rules. | S | | 24 | **Protocol filtering** | 🟠 With SDK dep | VM-level API would accept protocol specifiers. | S | -> **Example (#22–#24 — rule granularity).** These are three facets of one GA egress rule — *which* CIDR, *which* ports, *which* protocol — all on the same SDK dependency, because today's WSLC rule builder emits a bare `iptables -A OUTPUT -d -j ACCEPT/DROP` (`policy_mapping.rs:204-219`): whole host, all ports, all protocols, IPv4 only. The GA rule below exercises all three: +> **Example (#22–#24 — rule granularity).** These are three facets of one supported egress rule — *which* CIDR, *which* ports, *which* protocol — all awaiting VM-level enforcement. The removed in-container rule builder only selected a whole host and could not enforce these facets. The example below exercises all three: > > ```json > { diff --git a/docs/process-container/networking.md b/docs/process-container/networking.md index dbb11e17c..099873a32 100644 --- a/docs/process-container/networking.md +++ b/docs/process-container/networking.md @@ -253,7 +253,9 @@ Do not infer otherwise from the schema: - Per-source or per-port inbound rules. GA ingress is limited to the `default` and `hostLoopback` allow/deny toggles. -See the parent doc on the last 4. +The egress schema selects numeric destinations, protocols, and ports, not +durable DNS names or application payloads. Its ingress schema has only +`default` and `hostLoopback` toggles; it cannot select inbound peers or ports. ## 2. Supported-contract selection and downlevel behavior diff --git a/docs/schema.md b/docs/schema.md index cca93d31d..f743059bc 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -46,15 +46,20 @@ and put the loopback proxy endpoint in runtime configuration: ``` Direct egress rules and `runtimeConfig.networkProxy` select different -connectivity models and cannot be combined. Both ingress controls deny when -omitted, and `hostLoopback` resolves independently of `ingress.default` rather -than inheriting it, so host-loopback access must be requested explicitly. +connectivity models and cannot be combined. Direct mode applies numeric CIDR, +protocol, and port rules where the backend supports them. A runtime proxy +names a caller-managed HTTP/S endpoint; the proxy owns any destination +filtering. Whether MXC can restrict raw-socket traffic to that endpoint +depends on the backend; see its guide. Both ingress controls deny when +omitted, and `hostLoopback` resolves independently of +`ingress.default` rather than inheriting it, so host-loopback access must be +requested explicitly. A ProcessContainer proxy requires `ingress.default: "allow"`. Identity-scoped proxies set a non-blank `allowedProxyPeer` and keep `hostLoopback: "deny"`; identity-less host proxies omit `allowedProxyPeer` and require `hostLoopback: "allow"`. The identity-less route is a weaker development/testing -compatibility deployment because it opens both host-loopback directions; it is -not the strict proxy-endpoint exception defined by the shared model-2 policy. +deployment: it opens both host-loopback directions without restricting access +to a named proxy peer. It does not enforce a proxy-only host-loopback exception. ```json { @@ -84,26 +89,6 @@ No supported exact contract accepts them. Migrate existing policies to directional fields and `runtimeConfig.networkProxy` rather than changing the version string alone. -#### Historical legacy network host-list semantics (retired) - -In the retired contracts, host lists refined `defaultPolicy`; they did not -replace it. This table describes historical behavior, not supported authoring: - -| `defaultPolicy` | `allowedHosts` | `blockedHosts` | Result | -| --- | --- | --- | --- | -| `block` | empty | empty | Valid: no egress | -| `block` | non-empty | empty | Valid: allow only listed destinations | -| `block` | empty | non-empty | Invalid: a blocklist cannot refine a block default without an allowlist | -| `block` | non-empty | non-empty | Valid shared policy: explicit blocks override allowed destinations; backends may reject if they cannot represent both lists | -| `allow` | empty | empty | Valid: unrestricted egress | -| `allow` | empty | non-empty | Valid: allow all except listed destinations | -| `allow` | non-empty | empty | Invalid: an allowlist cannot refine an allow default | -| `allow` | non-empty | non-empty | Invalid: `allowedHosts` cannot be used with an allow default | - -For the valid block-default combination containing both lists, explicit blocks -take precedence over allowed destinations. A backend that cannot represent both -lists must reject the combination rather than dropping either list. - ### IsolationSession unrestricted networking (0.9) IsolationSession cannot restrict networking. Exact v0.9 requests must describe @@ -127,8 +112,7 @@ that actual posture through the standard directional network fields: All three directional values must be explicitly `allow`; omission defaults to deny. Legacy network fields, rules, mixed postures, and proxies are rejected. An absent or empty `network` object is rejected. Exact v0.9 IsolationSession -does not require an experimental execution opt-in. Earlier published contracts -remain immutable history but are no longer accepted. +does not require an experimental execution opt-in. Every complete request that carries a process requires a non-empty `process.commandLine`. The Windows native CLI may accept a template without that field when the command is supplied after `--`; `wxc-exec.exe` inserts or diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 7786ae378..ea6fc34f8 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -540,7 +540,7 @@ rejection records from the same invocation. A successful launch emits no | `mxc.PolicyHash` | Every launch, after the effective request is resolved | `backend`, `policy_hash`, `config_schema_version` | | `mxc.SandboxIdentity` | After a successful state-aware phase | `backend`, `identity`, `phase` | | `mxc.EnforcementDegraded` | ProcessContainer dispatch resolved below the preferred tier | `backend`, `identity`, `tier`, `needs_dacl_augmentation`, `effective_enforcement_level`, `degradation_reasons`, `degradation_reason_count` | -| `mxc.NetworkPolicyApplied` | After network policy setup, on success **and** failure | `backend`, `identity`, `tier` (no `pid` yet), plus `enforcement_mode`, `default_policy`, `proxy_port`, `firewall_rules_created`, `firewall_applied`, `status` | +| `mxc.NetworkPolicyApplied` | AppContainer: after network setup, before process launch. BaseContainer: success after launch. Both tiers: failure when network setup fails | `backend`, `identity`, `tier` (no `pid` field), plus `enforcement_mode`, `default_policy`, `proxy_port`, `firewall_rules_created`, `firewall_applied`, `status` | | `mxc.ProcessExited` | Sandboxed process exited on its own | `exit_code` | | `mxc.ProcessTimedOut` | `scriptTimeout` breached | `timeout_ms` | | `mxc.ProcessKillFailed` | A kill/terminate call failed (**failure only**) | `kill_method`, `error_code` | @@ -550,7 +550,14 @@ rejection records from the same invocation. A successful launch emits no For the ProcessContainer AppContainer fallback, the firewall fields remain in these records for compatibility: no local firewall rules are created or removed, `firewall_applied` is `false`, and `firewall_removal_ok` is `true`. -Proxy setup failures still set `mxc.NetworkPolicyApplied.status` to failure. +AppContainer proxy-shim startup failures set `mxc.NetworkPolicyApplied.status` +to `failure` and report `proxy_port: 0` after the coordinator cleans up. +AppContainer reports network success before spawning the process; a later +launch failure can therefore follow a successful network record. +BaseContainer reports `failure` if native PSEC setup (including its proxy +policy) fails, with the requested proxy port; it emits `success` only after +launch succeeds. Unrelated pre-setup and process-launch failures do not emit a +BaseContainer network record. ### Error semantics: `FallbackError` vs `ActivityError` @@ -641,13 +648,14 @@ Excluded, and why: | `source_contract` | External exact-contract provenance used for diagnostics and telemetry attribution, not enforcement. | | `telemetry`, internal `test` feature | No enforcement effect. | | proxy `original_url` | Can embed `user:password@`. The host and port *are* hashed. | -| `dry_run`, `testing_features_enabled` | Invocation modes, not policy. | +| `dry_run` | Invocation mode, not policy. | | `experimental_enabled` | Authorizes selecting an experimental backend, not enforcement; changing it leaves policy identity unchanged. | -The retired network compatibility marker is no longer part of the projection. -Hashes from builds that included it can differ even when the supported -effective policy is unchanged; compare policy hashes across builds only with -that projection change in mind. +The retired network compatibility marker, legacy network model fields, and +built-in test proxy discriminator are no longer part of the projection. Hashes +from builds that included them can differ even when the supported effective +policy is unchanged; compare policy hashes across builds only with those +projection changes in mind. Enforcement-relevant backend configuration is hashed from `ExecutionRequest.windows_sandbox` and `ExecutionRequest.wslc`. The canonical diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ContainerRequestSections.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ContainerRequestSections.cs index 7ffce0808..98caa567f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ContainerRequestSections.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/ContainerRequestSections.cs @@ -178,7 +178,7 @@ public sealed class NetworkRulePolicy public List? Ports { get; set; } } -/// Schema-0.8 outbound network policy. +/// Directional outbound network policy. public sealed class NetworkEgressPolicy { /// Action for traffic not matched by a rule. @@ -194,7 +194,7 @@ public sealed class NetworkEgressPolicy public List? Deny { get; set; } } -/// Schema-0.8 inbound and host-loopback network policy. +/// Directional inbound and host-loopback network policy. public sealed class NetworkIngressPolicy { /// Default inbound action. @@ -206,7 +206,7 @@ public sealed class NetworkIngressPolicy public NetworkAction? HostLoopback { get; set; } } -/// Schema-0.8 runtime network values. +/// Runtime network values. public sealed class NetworkRuntimeConfig { /// diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs index b76f7b1c5..a48e08a31 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs @@ -11,8 +11,6 @@ use std::collections::HashSet; use crate::mxc_common::filesystem_resolve::FsIntent; use crate::mxc_common::models::{ExecutionRequest, NetworkAction, ProxyAddress}; -#[cfg(any(test, target_os = "linux"))] -use crate::mxc_common::models::{NetworkEnforcementMode, NetworkPolicy}; use crate::mxc_common::proxy_env::{is_managed_proxy_key, PROXY_SET_KEYS}; /// The fixed prefix of the command bwrap is asked to run. @@ -135,32 +133,6 @@ impl ResolvedNetworkMode { } } -/// Interim protection for directly constructed requests while the shared -/// runtime model still exposes fields retired from every supported contract. -#[cfg(any(test, target_os = "linux"))] -pub(crate) fn retired_network_fields_rejection(request: &ExecutionRequest) -> Option<&'static str> { - let policy = &request.policy; - (policy.default_network_policy != NetworkPolicy::Block - || policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities - || policy.allow_local_network - || !policy.allowed_hosts.is_empty() - || !policy.blocked_hosts.is_empty()) - .then_some( - "Bubblewrap: retired network.defaultPolicy, enforcementMode, allowLocalNetwork, \ - allowedHosts and blockedHosts are not supported; use network.egress and \ - network.ingress.", - ) -} - -/// The built-in test proxy has no supported exact contract spelling. -#[cfg(any(test, target_os = "linux"))] -pub(crate) fn builtin_proxy_rejection(request: &ExecutionRequest) -> Option<&'static str> { - request.policy.network_proxy.builtin_test_server.then_some( - "Bubblewrap: network.proxy.builtinTestServer is retired; configure an externally \ - managed runtimeConfig.networkProxy.", - ) -} - /// Refuse an inbound posture Bubblewrap cannot honor. /// /// The backend declares `INGRESS_DEFAULT` and `HOST_LOOPBACK` in @@ -205,7 +177,7 @@ pub const BWRAP_HOST_LOOPBACK_ALLOW: &str = network.ingress.hostLoopback='deny'."; /// Rejection text for a proxy combined with direct egress. -#[cfg(any(test, target_os = "linux"))] +#[cfg(any(target_os = "linux", test))] pub(crate) const BWRAP_PROXY_DIRECTIONAL_EGRESS: &str = "Bubblewrap: runtimeConfig.networkProxy cannot be combined with direct network.egress rules \ or default='allow'. \ @@ -215,7 +187,7 @@ pub(crate) const BWRAP_PROXY_DIRECTIONAL_EGRESS: &str = remove runtimeConfig.networkProxy and express the policy with network.egress."; /// Reject direct egress that proxy-only routing would otherwise discard. -#[cfg(any(test, target_os = "linux"))] +#[cfg(any(target_os = "linux", test))] pub(crate) fn proxy_with_egress_rejection(request: &ExecutionRequest) -> Option<&'static str> { let proxy = &request.policy.network_proxy; if !proxy.is_enabled() { @@ -811,24 +783,6 @@ mod tests { assert_eq!(directional_network_rejection(&request), None); } - #[test] - fn directly_constructed_retired_network_fields_are_refused() { - let mut request = base_request(); - let mut cases: [fn(&mut ExecutionRequest); 5] = [ - |r| r.policy.default_network_policy = NetworkPolicy::Allow, - |r| r.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall, - |r| r.policy.allow_local_network = true, - |r| r.policy.allowed_hosts.push("192.0.2.1".into()), - |r| r.policy.blocked_hosts.push("192.0.2.2".into()), - ]; - for set_field in &mut cases { - set_field(&mut request); - assert!(retired_network_fields_rejection(&request).is_some()); - request = base_request(); - } - assert!(retired_network_fields_rejection(&request).is_none()); - } - #[test] fn only_external_runtime_proxies_are_accepted() { let mut request = directional_egress_request(NetworkAction::Deny, false); @@ -842,8 +796,6 @@ mod tests { proxy_with_egress_rejection(&request), Some(BWRAP_PROXY_DIRECTIONAL_EGRESS) ); - request.policy.network_proxy.builtin_test_server = true; - assert!(builtin_proxy_rejection(&request).is_some()); } #[test] diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index eb1de2274..b90f6169d 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -30,7 +30,6 @@ use crate::mxc_common::sandbox_process::{ spawn_discard, take_boxed_read, take_boxed_write, wait_with_timeout, NativeStdio, PtySize, SandboxBackend, SandboxProcess, StdioMode, StreamCloser, WaitError, }; -use crate::mxc_common::unix_proxy_coordinator::UnixProxyCoordinator; use crate::mxc_common::validator::{ validate_common, validate_network_policy_support, NetworkPolicySupport, }; @@ -179,12 +178,6 @@ impl BubblewrapScriptRunner { where F: FnOnce() -> Result, { - if let Some(reason) = bwrap_command::retired_network_fields_rejection(request) { - return Err(ScriptResponse::error(reason)); - } - if let Some(reason) = bwrap_command::builtin_proxy_rejection(request) { - return Err(ScriptResponse::error(reason)); - } validate_network_policy_support(request, self.network_policy_support())?; // User-input validation runs before the environmental `bwrap` @@ -371,50 +364,34 @@ impl BubblewrapScriptRunner { logger: &mut Logger, stdio: StdioMode, ) -> Result { - // 1. Start the network proxy if configured. Must happen before - // arg-building so the proxy's loopback address can be injected as - // HTTP_PROXY / HTTPS_PROXY into the sandbox environment. - let mut proxy = UnixProxyCoordinator::new(); - if request.policy.network_proxy.is_enabled() { - if let Err(err) = proxy.start( - &request.policy.network_proxy, - "127.0.0.1", - &[], - &[], - crate::mxc_common::models::NetworkPolicy::Block, - logger, - ) { - return Err(ScriptResponse::error(&format!( - "Bubblewrap: failed to start network proxy: {}", - err - ))); - } + // The caller manages the proxy; pass its address into the sandbox + // environment and restrict direct egress to that endpoint. + let configured_proxy = request.policy.network_proxy.address.as_ref(); + if let Some(address) = configured_proxy { + logger.log_line(&format!( + "Unix network proxy active: {}", + crate::mxc_common::proxy_env::redact_proxy_url(&address.to_url()) + )); } - let network_mode = ResolvedNetworkMode::from_request(request, proxy.is_active()); + let network_mode = ResolvedNetworkMode::from_request(request, configured_proxy.is_some()); let sandbox_proxy = if network_mode == ResolvedNetworkMode::ProxyOnly { - match proxy.address() { - Some(address) => match proxy_network::SandboxProxy::resolve(address) { - Ok(resolved) => Some(resolved), - Err(error) => { - proxy.stop(logger); - return Err(ScriptResponse::error(&error)); - } - }, - None => { - proxy.stop(logger); - return Err(ScriptResponse::error( - "Bubblewrap: proxy mode was selected without a resolved proxy address.", - )); - } - } + let address = configured_proxy.ok_or_else(|| { + ScriptResponse::error( + "Bubblewrap: proxy mode was selected without a resolved proxy address.", + ) + })?; + Some( + proxy_network::SandboxProxy::resolve(address) + .map_err(|error| ScriptResponse::error(&error))?, + ) } else { None }; let proxy_address = sandbox_proxy .as_ref() .map(|resolved| resolved.address()) - .or_else(|| proxy.address()); + .or(configured_proxy); let mut proxy_network = match sandbox_proxy.as_ref() { // The workload dials the sandbox-visible address, so that is what @@ -429,10 +406,7 @@ impl BubblewrapScriptRunner { request.script_timeout, ) { Ok(network) => Some(network), - Err(error) => { - proxy.stop(logger); - return Err(ScriptResponse::error(&error)); - } + Err(error) => return Err(ScriptResponse::error(&error)), } } // Direct directional egress uses the same private namespace. @@ -445,10 +419,7 @@ impl BubblewrapScriptRunner { Some(plan) => plan, None => match network_rules::EgressPlan::for_request(request) { Ok(plan) => plan, - Err(error) => { - proxy.stop(logger); - return Err(ScriptResponse::error(&error)); - } + Err(error) => return Err(ScriptResponse::error(&error)), }, }; match proxy_network::ProxyNetworkNamespace::start( @@ -459,10 +430,7 @@ impl BubblewrapScriptRunner { request.script_timeout, ) { Ok(network) => Some(network), - Err(error) => { - proxy.stop(logger); - return Err(ScriptResponse::error(&error)); - } + Err(error) => return Err(ScriptResponse::error(&error)), } } None => None, @@ -482,7 +450,6 @@ impl BubblewrapScriptRunner { Ok(startup) => Some(startup), Err(error) => { stop_proxy_network(&mut proxy_network, logger); - proxy.stop(logger); return Err(ScriptResponse::error(&error)); } }, @@ -527,7 +494,6 @@ impl BubblewrapScriptRunner { Ok(pty) => Some(pty), Err(error) => { stop_proxy_network(&mut proxy_network, logger); - proxy.stop(logger); return Err(ScriptResponse::error(&format!( "Bubblewrap: failed to allocate PTY: {error}" ))); @@ -550,7 +516,6 @@ impl BubblewrapScriptRunner { Ok(process) => process, Err(error) => { stop_proxy_network(&mut proxy_network, logger); - proxy.stop(logger); return Err(ScriptResponse::error(&format!( "Bubblewrap: failed to spawn bwrap: {}", error @@ -577,7 +542,6 @@ impl BubblewrapScriptRunner { let _ = child.kill(); let _ = child.wait(); stop_proxy_network(&mut proxy_network, logger); - proxy.stop(logger); return Err(ScriptResponse::error(&error)); } } @@ -600,7 +564,6 @@ impl BubblewrapScriptRunner { let _ = child.kill(); let _ = child.wait(); stop_proxy_network(&mut proxy_network, logger); - proxy.stop(logger); let error = out_result.err().or(err_result.err()); return Err(ScriptResponse::error(&format!( "Bubblewrap: failed to wrap stdio pipes: {}", @@ -632,7 +595,6 @@ impl BubblewrapScriptRunner { stderr_canceller, pty, group, - proxy, proxy_network, monitor, timeout, @@ -661,7 +623,6 @@ struct BwrapChild { /// termination signals the whole group; `false` for `Inherit` mode, where /// killing bwrap relies on `--die-with-parent` to take the sandbox with it. group: bool, - proxy: UnixProxyCoordinator, proxy_network: Option, monitor: Option, timeout: Option, @@ -675,7 +636,7 @@ impl BwrapChild { self.child.lock().unwrap_or_else(PoisonError::into_inner) } - /// Tear down per-run proxy and private network state. + /// Tear down per-run private network state. fn cleanup(&mut self, logger: &mut Logger) { // Stopping the supervisor closes the descriptor the monitor watches, so // disarming first is what keeps a normal teardown from reading as a @@ -684,7 +645,6 @@ impl BwrapChild { if let Some(mut network) = self.proxy_network.take() { network.stop(logger); } - self.proxy.stop(logger); } } @@ -1226,7 +1186,6 @@ mod tests { "proxy.example.com".into(), 3128, )), - builtin_test_server: false, }; req } @@ -1493,7 +1452,6 @@ mod tests { request.policy.runtime_network_proxy_specified = true; request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 3128)), - builtin_test_server: false, }; request }, @@ -1633,7 +1591,6 @@ mod tests { req.policy.network_egress = Some(NetworkEgressPolicy::default()); req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("[::1]".into(), 3128)), - builtin_test_server: false, }; let runner = BubblewrapScriptRunner::new(); @@ -1702,7 +1659,6 @@ mod tests { }); req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".into(), 3128)), - builtin_test_server: false, }; let unavailable = bwrap_version::BwrapUnavailable::NotFound; @@ -1723,7 +1679,6 @@ mod tests { req.policy.network_egress = Some(NetworkEgressPolicy::default()); req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("2001:db8::1".into(), 3128)), - builtin_test_server: false, }; let runner = BubblewrapScriptRunner::new(); @@ -1752,7 +1707,6 @@ mod tests { req.policy.denied_paths = vec!["/etc/hosts".into()]; req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("proxy.example.com".into(), 3128)), - builtin_test_server: false, }; let runner = BubblewrapScriptRunner::new(); @@ -1779,7 +1733,6 @@ mod tests { let mut req = base_request(); req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("proxy.example.com".into(), 3128)), - builtin_test_server: false, }; // As written, this matches nothing the check looks for. @@ -1809,7 +1762,6 @@ mod tests { req.policy.denied_paths = vec!["/etc/hosts".into()]; req.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".into(), 3128)), - builtin_test_server: false, }; let unavailable = bwrap_version::BwrapUnavailable::NotFound; @@ -1820,34 +1772,6 @@ mod tests { assert_eq!(err.error_message, expected); } - /// Direct callers can construct a retired policy even though exact - /// contracts cannot. Refuse every non-default legacy field before probing. - #[test] - fn validate_rejects_retired_fields_before_the_environment_probe() { - let setters: [fn(&mut ExecutionRequest); 5] = [ - |r| r.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Allow, - |r| { - r.policy.network_enforcement_mode = - crate::mxc_common::models::NetworkEnforcementMode::Firewall - }, - |r| r.policy.allow_local_network = true, - |r| r.policy.allowed_hosts.push("203.0.113.7".into()), - |r| r.policy.blocked_hosts.push("203.0.113.8".into()), - ]; - for set_field in setters { - let mut req = base_request(); - set_field(&mut req); - let err = BubblewrapScriptRunner::new() - .validate_prepared_with_probe(&req, || panic!("retired policy reached bwrap probe")) - .unwrap_err(); - assert!( - err.error_message.contains("retired network"), - "{}", - err.error_message - ); - } - } - #[test] fn validate_accepts_programmatic_directional_cidr_rules() { use crate::mxc_common::models::{NetworkEgressPolicy, NetworkPeer, NetworkRule}; diff --git a/src/mxc-sdk/src/backends/hyperlight/common/mod.rs b/src/mxc-sdk/src/backends/hyperlight/common/mod.rs index d6f4d3bb3..6e03c1874 100644 --- a/src/mxc-sdk/src/backends/hyperlight/common/mod.rs +++ b/src/mxc-sdk/src/backends/hyperlight/common/mod.rs @@ -99,9 +99,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ - ExecutionRequest, HyperlightRuntime, NetworkEnforcementMode, NetworkPolicy, ScriptResponse, -}; +use crate::mxc_common::models::{ExecutionRequest, HyperlightRuntime, ScriptResponse}; use crate::mxc_common::script_runner::ScriptRunner; use crate::mxc_common::validator::{ validate_common, validate_network_policy_support, NetworkPolicySupport, @@ -451,18 +449,6 @@ impl HyperlightScriptRunner { if !request.working_directory.is_empty() { return Err(RunnerError::Preflight(ERR_WORKDIR.to_string())); } - if request.policy.default_network_policy != NetworkPolicy::Block - || request.policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities - || request.policy.allow_local_network - || !request.policy.allowed_hosts.is_empty() - || !request.policy.blocked_hosts.is_empty() - { - return Err(RunnerError::Preflight( - "retired network fields are not supported by Hyperlight; the guest runs without networking" - .to_string(), - )); - } - // Denied paths: block early if any appears in the allow lists. for denied in &request.policy.denied_paths { for allowed in request @@ -1598,7 +1584,7 @@ fn os_data_home() -> PathBuf { mod tests { use super::*; use crate::mxc_common::logger::Mode; - use crate::mxc_common::models::{ContainerPolicy, NetworkPolicy}; + use crate::mxc_common::models::ContainerPolicy; fn runner() -> HyperlightScriptRunner { HyperlightScriptRunner::new() @@ -2125,39 +2111,6 @@ mod tests { .unwrap(); } - #[test] - fn retired_network_fields_are_rejected_before_boot() { - for policy in [ - ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - }, - ContainerPolicy { - allowed_hosts: vec!["a.example".to_string()], - ..Default::default() - }, - ContainerPolicy { - blocked_hosts: vec!["b.example".to_string()], - ..Default::default() - }, - ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - ..Default::default() - }, - ContainerPolicy { - allow_local_network: true, - ..Default::default() - }, - ] { - let request = ExecutionRequest { - policy, - ..Default::default() - }; - let error = runner().validate_runner(&request).unwrap_err(); - assert!(error.error_message.contains("retired network fields")); - } - } - #[test] fn explicit_directional_allow_is_rejected_before_boot() { let mut request = ExecutionRequest::default(); @@ -2169,22 +2122,6 @@ mod tests { assert!(error.error_message.contains("network.egress.default")); } - #[test] - fn direct_execution_rejects_retired_host_lists_before_boot() { - let mut r = runner(); - let request = ExecutionRequest { - policy: ContainerPolicy { - allowed_hosts: vec!["a.example".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - let resp = r.execute(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!(resp.error_message.contains("retired network fields")); - } - #[test] fn direct_execute_rejects_network_policy_before_boot() { let mut request = ExecutionRequest { diff --git a/src/mxc-sdk/src/backends/isolation_session/common/policy.rs b/src/mxc-sdk/src/backends/isolation_session/common/policy.rs index 23911f243..a0b530cae 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/policy.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/policy.rs @@ -27,9 +27,7 @@ //! therefore refused wherever a process is launched: one-shot and exec. use crate::mxc_common::default_env::EnvResolution; -use crate::mxc_common::models::{ - ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, -}; +use crate::mxc_common::models::{ExecutionRequest, NetworkAction}; use super::error::IsolationSessionError; @@ -43,8 +41,8 @@ const ERR_UI_POLICY: &str = "UI policy is not supported by the isolation session backend that enforces UI policy if you need one"; const ERR_NETWORK_POLICY: &str = "the network is unrestricted and cannot be filtered or denied; \ describe that posture with network.egress.default=allow, \ - network.ingress.default=allow, and network.ingress.hostLoopback=allow; do not supply rules, \ - proxy settings, or legacy network fields, or use a backend that enforces network policy"; + network.ingress.default=allow, and network.ingress.hostLoopback=allow; do not supply rules \ + or proxy settings, or use a backend that enforces network policy"; const ERR_PROXY_POLICY: &str = "the network cannot be routed through a proxy; remove network.proxy \ (the container's network is unrestricted and unproxied)"; @@ -140,19 +138,11 @@ fn validate_provision_network_policy( request: &ExecutionRequest, ) -> Result<(), IsolationSessionError> { let policy = &request.policy; - let is_directional_allow = policy.default_network_policy == NetworkPolicy::Block - && !policy.allow_local_network - && policy.allowed_hosts.is_empty() - && policy.blocked_hosts.is_empty() - && policy.network_enforcement_mode == NetworkEnforcementMode::Capabilities - && policy.network_egress.as_ref().is_some_and(|egress| { - egress.default == NetworkAction::Allow - && egress.allow.is_empty() - && egress.deny.is_empty() - }) - && policy.network_ingress.as_ref().is_some_and(|ingress| { - ingress.default == NetworkAction::Allow && ingress.host_loopback == NetworkAction::Allow - }); + let is_directional_allow = policy.network_egress.as_ref().is_some_and(|egress| { + egress.default == NetworkAction::Allow && egress.allow.is_empty() && egress.deny.is_empty() + }) && policy.network_ingress.as_ref().is_some_and(|ingress| { + ingress.default == NetworkAction::Allow && ingress.host_loopback == NetworkAction::Allow + }); if !is_directional_allow { return Err(IsolationSessionError::Policy( ERR_NETWORK_POLICY.to_string(), @@ -182,15 +172,6 @@ mod tests { } } - /// The removed legacy unrestricted-network spelling. - fn canonical_allow_policy() -> ContainerPolicy { - ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - allow_local_network: true, - ..Default::default() - } - } - fn directional_allow_policy() -> ContainerPolicy { ContainerPolicy { network_egress: Some(NetworkEgressPolicy { @@ -307,18 +288,6 @@ mod tests { ); } - #[test] - fn provision_policy_rejects_legacy_allow() { - let request = ExecutionRequest { - policy: canonical_allow_policy(), - ..Default::default() - }; - assert_policy_err_contains( - validate_provision_policy(&request).unwrap_err(), - ERR_NETWORK_POLICY, - ); - } - #[test] fn provision_policy_accepts_directional_allow() { let request = ExecutionRequest { @@ -346,11 +315,6 @@ mod tests { }), ..directional_allow_policy() }, - ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - allow_local_network: true, - ..directional_allow_policy() - }, ContainerPolicy { network_egress: Some(NetworkEgressPolicy { default: NetworkAction::Allow, @@ -373,8 +337,8 @@ mod tests { #[test] fn provision_policy_rejects_default_request() { - // Absent network policy → domain default `Block`, which the backend - // cannot enforce, so provision refuses it. + // Absent network policy defaults to deny, which the backend cannot + // enforce, so provision refuses it. let request = ExecutionRequest::default(); assert_policy_err_contains( validate_provision_policy(&request).unwrap_err(), @@ -382,106 +346,13 @@ mod tests { ); } - #[test] - fn provision_policy_rejects_block_even_with_local_network() { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - allow_local_network: true, - ..Default::default() - }, - ..Default::default() - }; - assert_policy_err_contains( - validate_provision_policy(&request).unwrap_err(), - ERR_NETWORK_POLICY, - ); - } - - #[test] - fn provision_policy_rejects_allow_without_local_network() { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - allow_local_network: false, - ..Default::default() - }, - ..Default::default() - }; - assert_policy_err_contains( - validate_provision_policy(&request).unwrap_err(), - ERR_NETWORK_POLICY, - ); - } - - #[test] - fn provision_policy_rejects_allowed_hosts() { - let request = ExecutionRequest { - policy: ContainerPolicy { - allowed_hosts: vec!["example.com".to_string()], - ..directional_allow_policy() - }, - ..Default::default() - }; - assert_policy_err_contains( - validate_provision_policy(&request).unwrap_err(), - ERR_NETWORK_POLICY, - ); - } - - #[test] - fn provision_policy_rejects_blocked_hosts() { - let request = ExecutionRequest { - policy: ContainerPolicy { - blocked_hosts: vec!["evil.com".to_string()], - ..directional_allow_policy() - }, - ..Default::default() - }; - assert_policy_err_contains( - validate_provision_policy(&request).unwrap_err(), - ERR_NETWORK_POLICY, - ); - } - - #[test] - fn provision_policy_rejects_firewall_enforcement() { - let request = ExecutionRequest { - policy: ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - ..canonical_allow_policy() - }, - ..Default::default() - }; - assert_policy_err_contains( - validate_provision_policy(&request).unwrap_err(), - ERR_NETWORK_POLICY, - ); - } - - #[test] - fn provision_policy_rejects_both_enforcement() { - let request = ExecutionRequest { - policy: ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Both, - ..canonical_allow_policy() - }, - ..Default::default() - }; - assert_policy_err_contains( - validate_provision_policy(&request).unwrap_err(), - ERR_NETWORK_POLICY, - ); - } - #[test] fn provision_policy_rejects_proxy() { - // Canonical on the network axis, but a proxy the backend cannot route. + // Unrestricted on the network axis, but a proxy the backend cannot route. let request = ExecutionRequest { policy: ContainerPolicy { network_proxy: ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }, ..directional_allow_policy() }, @@ -517,7 +388,7 @@ mod tests { let request = ExecutionRequest { policy: ContainerPolicy { ui_specified: true, - ..canonical_allow_policy() + ..directional_allow_policy() }, ..Default::default() }; @@ -547,7 +418,7 @@ mod tests { policy: ContainerPolicy { ui_specified: true, ui: UiPolicy::default(), - ..canonical_allow_policy() + ..directional_allow_policy() }, ..Default::default() }; @@ -673,11 +544,11 @@ mod tests { #[test] fn post_provision_policy_rejects_specified_network() { // Any supplied network policy is refused post-provision (fixed at - // provision), regardless of value — here a canonical allow. + // provision), regardless of value — here a directional allow. let request = ExecutionRequest { policy: ContainerPolicy { network_specified: true, - ..canonical_allow_policy() + ..directional_allow_policy() }, ..Default::default() }; @@ -689,9 +560,8 @@ mod tests { #[test] fn post_provision_policy_rejects_specified_network_even_when_block() { - // Closes the presence-signal blind spot: an explicit default-valued - // (Block) network is indistinguishable from absent in the domain model, - // so the `network_specified` flag — not the value — drives the refusal. + // Even an explicit default-valued (deny) network is rejected on + // presence, not just on its values. let request = ExecutionRequest { policy: ContainerPolicy { network_specified: true, diff --git a/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs b/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs index 38ea3a506..b31c8a9b8 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/state_aware.rs @@ -728,7 +728,6 @@ mod tests { policy: ContainerPolicy { network_proxy: ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }, ..Default::default() }, diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index 132e8e6c2..9abf2c93b 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -10,8 +10,7 @@ use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ - ContainerPolicy, ExecutionRequest, LifecycleConfig, LxcConfig, NetworkEnforcementMode, - NetworkPolicy, ScriptResponse, + ContainerPolicy, ExecutionRequest, LifecycleConfig, LxcConfig, ScriptResponse, }; use crate::mxc_common::sandbox_process::{SandboxBackend, SandboxProcess, StdioMode}; use crate::mxc_common::script_runner::ScriptRunner; @@ -959,18 +958,8 @@ pub const LXC_INHERIT_STDIO_UNSUPPORTED: &str = pub const LXC_STREAMING_LINUX_ONLY: &str = "LXC: sandboxes can only be launched on Linux."; -fn retired_network_field(policy: &ContainerPolicy) -> Option<&'static str> { - if policy.default_network_policy != NetworkPolicy::Block { - Some("network.defaultPolicy") - } else if policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities { - Some("network.enforcementMode") - } else if policy.allow_local_network { - Some("network.allowLocalNetwork") - } else if !policy.allowed_hosts.is_empty() { - Some("network.allowedHosts") - } else if !policy.blocked_hosts.is_empty() { - Some("network.blockedHosts") - } else if policy.network_proxy.is_enabled() { +fn unsupported_network_proxy(policy: &ContainerPolicy) -> Option<&'static str> { + if policy.network_proxy.is_enabled() { Some("network.proxy") } else { None @@ -989,10 +978,10 @@ impl ScriptRunner for LxcScriptRunner { if request.policy.runtime_network_proxy_specified { return Err(ScriptResponse::error(LXC_RUNTIME_PROXY_UNSUPPORTED)); } - if let Some(field) = retired_network_field(&request.policy) { + if let Some(field) = unsupported_network_proxy(&request.policy) { return Err(ScriptResponse::error(&format!( - "LXC: {field} is retired and cannot be enforced; use network.egress / \ - network.ingress instead." + "LXC: {field} is not supported and cannot be enforced; \ + remove the proxy request." ))); } validate_network_policy_support(request, lxc_network_policy_support())?; @@ -1711,7 +1700,6 @@ mod tests { "proxy.example.com".to_string(), 3128, )), - ..Default::default() }, ..Default::default() }); @@ -2069,55 +2057,17 @@ mod tests { } #[test] - fn a_retired_enforcement_mode_is_refused() { - let policy = ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - ..Default::default() - }; - - let refusal = validating_runner() - .validate_runner(&request_with_policy(policy)) - .expect_err("a retired enforcement mode cannot configure LXC"); - - assert!(refusal.error_message.contains("network.enforcementMode")); - } - #[test] - fn retired_fields_are_refused_before_container_creation() { - type RetiredFieldCase = (&'static str, fn(&mut ContainerPolicy)); - let cases: &[RetiredFieldCase] = &[ - ("network.defaultPolicy", |p| { - p.default_network_policy = NetworkPolicy::Allow - }), - ("network.enforcementMode", |p| { - p.network_enforcement_mode = NetworkEnforcementMode::Both - }), - ("network.allowLocalNetwork", |p| { - p.allow_local_network = true - }), - ("network.allowedHosts", |p| { - p.allowed_hosts.push("192.0.2.10".into()) - }), - ("network.blockedHosts", |p| { - p.blocked_hosts.push("192.0.2.10".into()) - }), - ("network.proxy", |p| { - p.network_proxy.builtin_test_server = true - }), - ]; - - for &(field, set_field) in cases { - let mut policy = egress_policy_for_tests(); - set_field(&mut policy); - let runner = runner_for_guard_tests(field); - let mut logger = Logger::new(Mode::Buffer); - let response = runner.run_internal(&request_with_policy(policy), &mut logger); - assert!( - response.error_message.contains(field), - "{field} must fail before creating a container: {}", - response.error_message - ); - assert!(!logger.get_buffer().contains("Creating LXC container")); - } + fn proxy_is_refused_before_container_creation() { + let mut policy = egress_policy_for_tests(); + policy.network_proxy.address = Some(crate::mxc_common::models::ProxyAddress::new( + "127.0.0.1".into(), + 8080, + )); + let runner = runner_for_guard_tests("network.proxy"); + let mut logger = Logger::new(Mode::Buffer); + let response = runner.run_internal(&request_with_policy(policy), &mut logger); + assert!(response.error_message.contains("network.proxy")); + assert!(!logger.get_buffer().contains("Creating LXC container")); } #[test] @@ -2650,7 +2600,6 @@ mod tests { "proxy.example.com".to_string(), 8080, )), - builtin_test_server: false, }; request } diff --git a/src/mxc-sdk/src/backends/nanvix/runner/mod.rs b/src/mxc-sdk/src/backends/nanvix/runner/mod.rs index fb84b0b71..aa1dc3bfc 100644 --- a/src/mxc-sdk/src/backends/nanvix/runner/mod.rs +++ b/src/mxc-sdk/src/backends/nanvix/runner/mod.rs @@ -39,7 +39,7 @@ //! `-allow-host-networking` to `nanvixd` only when egress.default, //! ingress.default, and ingress.hostLoopback all allow. All-deny (including //! omitted defaults) stays disconnected; mixed postures, egress rules, proxies, -//! and retired legacy network fields are rejected before execution. +//! and unsupported directional network postures are rejected before execution. //! //! Auto-discovery //! @@ -56,9 +56,7 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant}; use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ - ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, ScriptResponse, -}; +use crate::mxc_common::models::{ExecutionRequest, NetworkAction, ScriptResponse}; use crate::mxc_common::script_runner::ScriptRunner; use crate::mxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; @@ -100,9 +98,6 @@ const ERR_DENIED_PATHS: &str = concat!( "-- the guest has no host filesystem visibility. ", "Only readwrite_paths and readonly_paths are supported", ); -const ERR_LEGACY_NETWORK: &str = "NanVix does not support retired legacy network fields \ - (defaultPolicy, enforcementMode, allowLocalNetwork, allowedHosts, blockedHosts); \ - use network.egress and network.ingress"; const ERR_PROXY_POLICY: &str = "network proxy is not supported by the NanVix backend"; const ERR_DIRECTIONAL_NETWORK: &str = "NanVix supports only fully isolated or explicitly \ unrestricted directional networking: egress.default, ingress.default and ingress.hostLoopback \ @@ -501,14 +496,6 @@ impl NanVixScriptRunner { fn resolve_networking_mode(request: &ExecutionRequest) -> Result { let policy = &request.policy; - if policy.default_network_policy != NetworkPolicy::Block - || policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities - || policy.allow_local_network - || !policy.allowed_hosts.is_empty() - || !policy.blocked_hosts.is_empty() - { - return Err(NanVixError::Preflight(ERR_LEGACY_NETWORK.to_string())); - } if policy .network_egress .as_ref() @@ -1175,79 +1162,6 @@ mod tests { ); } - #[test] - fn directly_constructed_legacy_network_fields_fail_closed() { - let legacy_policies = [ - ( - "defaultPolicy", - ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - }, - ), - ( - "enforcementMode", - ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, - ..Default::default() - }, - ), - ( - "allowLocalNetwork", - ContainerPolicy { - allow_local_network: true, - ..Default::default() - }, - ), - ( - "allowedHosts", - ContainerPolicy { - allowed_hosts: vec!["192.0.2.1".to_string()], - ..Default::default() - }, - ), - ( - "blockedHosts", - ContainerPolicy { - blocked_hosts: vec!["192.0.2.1".to_string()], - ..Default::default() - }, - ), - ]; - for (field, legacy_policy) in legacy_policies { - let mut request = directional_request( - NetworkAction::Allow, - NetworkAction::Allow, - NetworkAction::Allow, - ); - request.script_code = "print(1)".to_string(); - request.policy.default_network_policy = legacy_policy.default_network_policy; - request.policy.network_enforcement_mode = legacy_policy.network_enforcement_mode; - request.policy.allow_local_network = legacy_policy.allow_local_network; - request.policy.allowed_hosts = legacy_policy.allowed_hosts; - request.policy.blocked_hosts = legacy_policy.blocked_hosts; - - let error = NanVixScriptRunner::new() - .validate_runner(&request) - .unwrap_err(); - assert!(error.error_message.contains(ERR_LEGACY_NETWORK), "{field}"); - assert!(command_arguments(&request).is_err(), "{field}"); - - let mut runner = NanVixScriptRunner::new(); - let mut logger = Logger::new(Mode::Buffer); - let response = runner.run(&request, &mut logger); - assert!( - response.error_message.contains(ERR_LEGACY_NETWORK), - "{field}" - ); - let response = runner.execute(&request, &mut logger); - assert!( - response.error_message.contains(ERR_LEGACY_NETWORK), - "{field}" - ); - } - } - // -- Directional network decision tests ------------------------------------ #[test] @@ -1287,10 +1201,6 @@ mod tests { let mut logger = Logger::new(Mode::Buffer); let resp = runner.run(&request, &mut logger); assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!( - !resp.error_message.contains(ERR_LEGACY_NETWORK), - "default request should not trigger network policy rejection" - ); assert!( !resp.error_message.contains(ERR_WORKDIR), "default request should not trigger workingDirectory rejection" @@ -1308,7 +1218,6 @@ mod tests { "127.0.0.1".to_string(), 8080, )), - builtin_test_server: false, }, ..Default::default() }, diff --git a/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs b/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs index 5b3606029..d3eabc205 100644 --- a/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/appcontainer_runner.rs @@ -39,7 +39,8 @@ use crate::mxc_common::audit::{ use crate::mxc_common::error::WxcError; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ - ContainmentBackend, ExecutionRequest, FailurePhase, SandboxOutputMetadata, ScriptResponse, + ContainerPolicy, ContainmentBackend, ExecutionRequest, FailurePhase, ProxyAddress, + SandboxOutputMetadata, ScriptResponse, }; use crate::mxc_common::process_util::{ create_std_pipes, InterruptiblePipeReader, OwnedHandle, PipeReadCanceller, PipeWriter, @@ -67,6 +68,7 @@ use crate::process_container_common::network_policy_helpers::{ add_default_network_capabilities, allows_network_egress, }; use crate::process_container_common::process_mitigation; +use crate::process_container_common::proxy_coordinator::ProxyCoordinator; pub(crate) const CAPTURE_DENIALS_FALLBACK_UNSUPPORTED_MSG: &str = "captureDenials requires either the native BaseContainer learning-mode APIs or an \ @@ -1506,10 +1508,36 @@ impl Drop for AppContainerScriptRunner { /// the sandboxed child. Created by [`AppContainerScriptRunner::prepare`] and /// torn down by [`AppContainerScriptRunner::teardown`] after the child exits. struct Prepared { - network_manager: crate::process_container_common::network_manager::NetworkManager, + proxy_coordinator: ProxyCoordinator, bfs_manager: crate::process_container_common::filesystem_bfs::FileSystemBfsManager, } +fn log_app_network_policy_audit( + policy: &ContainerPolicy, + identity: &str, + tier: &str, + proxy_address: Option<&ProxyAddress>, + network_result: &Result<(), WxcError>, + logger: &mut Logger, +) { + if logger.has_diagnostic_sink() { + let status = if network_result.is_ok() { + OperationStatus::Success + } else { + OperationStatus::Failure + }; + let record = + crate::process_container_common::network_policy_helpers::network_policy_applied_record( + policy, + sanitize_identity(identity), + tier, + proxy_address, + status, + ); + logger.log_audit_event(&record); + } +} + impl AppContainerScriptRunner { /// Set up the AppContainer for a run: initialise the SID, configure BFS /// filesystem policy, and start network enforcement. Shared by both stdio @@ -1520,7 +1548,6 @@ impl AppContainerScriptRunner { logger: &mut Logger, ) -> Result { use crate::process_container_common::filesystem_bfs::FileSystemBfsManager; - use crate::process_container_common::network_manager::NetworkManager; if request.experimental_enabled { if let Some(ref test) = request.test_feature { @@ -1574,51 +1601,35 @@ impl AppContainerScriptRunner { ); } - let mut network_manager = NetworkManager::new(); - let network_result = network_manager.start( - &principal_id, - &self.app_container_name, + let mut proxy_coordinator = ProxyCoordinator::new(); + let network_result = if request.policy.network_proxy.is_enabled() { + proxy_coordinator.start( + &request.policy.network_proxy, + &self.app_container_name, + &principal_id, + self.app_container_sid, + logger, + ) + } else { + Ok(()) + }; + log_app_network_policy_audit( &request.policy, - self.app_container_sid, + &self.app_container_name, + self.tier_str(), + proxy_coordinator.address(), + &network_result, logger, ); - if logger.has_diagnostic_sink() { - let status = if network_result.is_ok() { - OperationStatus::Success - } else { - OperationStatus::Failure - }; - let record = AuditEvent::new(AuditEventName::NetworkPolicyApplied) - .str("backend", ContainmentBackend::ProcessContainer.wire_name()) - .str("identity", sanitize_identity(&self.app_container_name)) - .str("tier", self.tier_str()) - .str( - "enforcement_mode", - crate::process_container_common::network_policy_helpers::CAPABILITIES_ENFORCEMENT_MODE, - ) - .str( - "default_policy", - crate::process_container_common::network_policy_helpers::audit_egress_default(&request.policy), - ) - .u64( - "proxy_port", - network_manager - .proxy_address() - .map(|address| address.port as u64) - .unwrap_or(0), - ) - .u64("firewall_rules_created", 0) - .bool("firewall_applied", false) - .str("status", status.as_str()); - logger.log_audit_event(&record); - } if crate::mxc_common::telemetry::is_active() { crate::mxc_common::telemetry::log_network_policy_applied( sanitize_identity(&self.app_container_name), crate::process_container_common::network_policy_helpers::CAPABILITIES_ENFORCEMENT_MODE, - crate::process_container_common::network_policy_helpers::audit_egress_default(&request.policy), - network_manager - .proxy_address() + crate::process_container_common::network_policy_helpers::audit_egress_default( + &request.policy, + ), + proxy_coordinator + .address() .map(|address| address.port as u64) .unwrap_or(0), ); @@ -1626,7 +1637,7 @@ impl AppContainerScriptRunner { match network_result { Ok(()) => { - self.proxy_address = network_manager.proxy_address().cloned(); + self.proxy_address = proxy_coordinator.address().cloned(); } Err(err) => { return Err(ScriptResponse::error(&err.to_string())); @@ -1634,14 +1645,14 @@ impl AppContainerScriptRunner { } Ok(Prepared { - network_manager, + proxy_coordinator, bfs_manager, }) } /// Tear down the per-run proxy and filesystem policy after the child exits. fn teardown(&self, prepared: &mut Prepared, preserve_policy: bool, logger: &mut Logger) { - let proxy_stopped = prepared.network_manager.stop_all(logger); + let proxy_stopped = prepared.proxy_coordinator.stop(logger); let bfs_requested = self.filesystem_mode == FilesystemMode::Bfs && prepared.bfs_manager.configured() && !preserve_policy; @@ -1781,14 +1792,6 @@ impl SandboxBackend for AppContainerScriptRunner { crate::mxc_common::error::DENIED_PATHS_NOT_SUPPORTED_MSG, )); } - if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { - return Err(ScriptResponse::rejected( - crate::mxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, - )); - } - crate::process_container_common::network_policy_helpers::reject_retired_network_policy( - &request.policy, - )?; Ok(()) } @@ -1889,8 +1892,8 @@ struct AppContainerSandboxProcess { // and this handle is owned exclusively by the caller (not shared), so it is // only ever touched from one thread at a time. // -// `NetworkManager` owns only the proxy coordinator's process-global handles -// and paths; no thread-affine COM interface or apartment state is retained. +// `ProxyCoordinator` owns only process-global handles and paths; no +// thread-affine COM interface or apartment state is retained. // Moving this handle across threads is therefore sound. unsafe impl Send for AppContainerSandboxProcess {} @@ -1989,10 +1992,7 @@ impl AppContainerSandboxProcess { if let Some(result) = &self.teardown_result { return result.clone().map_err(std::io::Error::other); } - let proxy_stopped = self - .prepared - .network_manager - .stop_all(&mut self.audit_logger); + let proxy_stopped = self.prepared.proxy_coordinator.stop(&mut self.audit_logger); let bfs_requested = self.filesystem_mode == FilesystemMode::Bfs && self.prepared.bfs_manager.configured() && !self.preserve_policy; @@ -2271,6 +2271,78 @@ impl Drop for AppContainerSandboxProcess { mod tests { use crate::mxc_common::audit::{TeardownSkipReason, TeardownStatus}; + #[test] + fn appcontainer_network_audit_records_proxyless_proxy_and_start_failure() { + use super::*; + use crate::mxc_common::models::{NetworkAction, NetworkEgressPolicy}; + + for (default, configured_proxy, start_failed, expected_default, expected_port, status) in [ + (NetworkAction::Deny, false, false, "block", 0, "success"), + (NetworkAction::Allow, false, false, "allow", 0, "success"), + (NetworkAction::Deny, true, false, "block", 8080, "success"), + (NetworkAction::Deny, true, true, "block", 0, "failure"), + ] { + let mut policy = ContainerPolicy { + network_egress: Some(NetworkEgressPolicy { + default, + ..Default::default() + }), + ..Default::default() + }; + if configured_proxy { + policy.network_proxy.address = + Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)); + } + let active_address = if start_failed { + None + } else { + policy.network_proxy.address.as_ref() + }; + let result = if start_failed { + Err(WxcError::NetworkProxy("shim startup failed".to_string())) + } else { + Ok(()) + }; + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("network-audit.log"); + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + logger.enable_file_sink(&path).expect("diagnostic sink"); + + log_app_network_policy_audit( + &policy, + "untrusted-profile-name", + FilesystemMode::Dacl.isolation_tier().as_str(), + active_address, + &result, + &mut logger, + ); + drop(logger); + + let output = std::fs::read_to_string(&path).expect("read audit"); + let (_, json) = output + .trim_end() + .split_once("] ") + .expect("timestamped audit record"); + let record: serde_json::Value = serde_json::from_str(json).expect("valid JSON"); + assert_eq!(output.lines().count(), 1); + assert_eq!( + record, + serde_json::json!({ + "event": "mxc.NetworkPolicyApplied", + "backend": "processcontainer", + "identity": "redacted", + "tier": "appcontainer-dacl", + "enforcement_mode": "capabilities", + "default_policy": expected_default, + "proxy_port": expected_port, + "firewall_rules_created": 0, + "firewall_applied": false, + "status": status, + }) + ); + } + } + #[test] fn released_resources_format_is_stable() { assert_eq!( @@ -2858,30 +2930,6 @@ mod tests { assert_eq!(*events.lock().unwrap(), vec!["discard".to_string()]); } - #[test] - fn validate_runner_rejects_allowed_hosts() { - let runner = AppContainerScriptRunner::new(); - let mut request = ExecutionRequest::default(); - request.policy.allowed_hosts = vec!["example.com".into()]; - - let err = runner - .validate(&request) - .expect_err("allowedHosts is not yet supported"); - assert!(err.error_message.contains("allowedHosts")); - } - - #[test] - fn validate_runner_rejects_blocked_hosts() { - let runner = AppContainerScriptRunner::new(); - let mut request = ExecutionRequest::default(); - request.policy.blocked_hosts = vec!["bad.example.com".into()]; - - let err = runner - .validate(&request) - .expect_err("blockedHosts is not yet supported"); - assert!(err.error_message.contains("blockedHosts")); - } - #[test] fn validate_runner_accepts_empty_policy() { let runner = AppContainerScriptRunner::new(); diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index 4d08349a3..62b2efb70 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -31,14 +31,15 @@ use windows_core::{PCWSTR, PWSTR}; use crate::mxc_common::api_set::is_api_set_implemented; use crate::mxc_common::audit::{ - sanitize_identity, AuditEvent, AuditEventName, KillMethod, TeardownSkipReason, TeardownStatus, + sanitize_identity, AuditEvent, AuditEventName, KillMethod, OperationStatus, TeardownSkipReason, + TeardownStatus, }; use crate::mxc_common::error::WxcError; use crate::mxc_common::log_symbols::EMOJI_SECTION; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ CaptureDenialsErrorOutput, CaptureDenialsOutput, ContainmentBackend, ExecutionRequest, - FailurePhase, ProxyAddress, SandboxOutputMetadata, ScriptResponse, + FailurePhase, SandboxOutputMetadata, ScriptResponse, }; use crate::mxc_common::process_util::{ create_std_pipes, InterruptiblePipeReader, OwnedHandle, PipeReadCanceller, PipeWriter, @@ -143,6 +144,26 @@ const PSEC_INGRESS_UNSUPPORTED_MSG: &str = const CREATE_PROCESS_IN_SECURITY_ENVIRONMENT_API: &str = "CreateProcessW(PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT)"; +fn log_base_network_policy_audit( + request: &ExecutionRequest, + identity: &str, + status: OperationStatus, + logger: &mut Logger, +) { + if logger.has_diagnostic_sink() { + let record = + crate::process_container_common::network_policy_helpers::network_policy_applied_record( + &request.policy, + sanitize_identity(identity), + crate::process_container_common::fallback_detector::IsolationTier::BaseContainer + .as_str(), + request.policy.network_proxy.address.as_ref(), + status, + ); + logger.log_audit_event(&record); + } +} + #[derive(Debug)] struct CaptureCleanupError { output: CaptureDenialsOutput, @@ -437,36 +458,8 @@ impl BaseContainerRunner { )); } - // Launch builtin test proxy if requested (before building spec so we have the port). - let mut request = request.clone(); - if request.policy.network_proxy.builtin_test_server { - match self.proxy_coordinator.launch_test_proxy(logger) { - Ok(port) => { - let addr = ProxyAddress::new("127.0.0.1".to_string(), port); - request.policy.network_proxy.address = Some(addr); - } - Err(e) => { - return Err(ScriptResponse::error(&format!( - "Failed to start builtin test proxy: {e}" - ))); - } - } - } - - // Log the effective proxy config after resolution. - if request.policy.network_proxy.is_enabled() { - let addr = request - .policy - .network_proxy - .address - .as_ref() - .map(|a| a.to_url()) - .unwrap_or_else(|| "".to_string()); - let _ = writeln!( - logger, - "effective proxy: {} (builtin_test_server={})", - addr, request.policy.network_proxy.builtin_test_server - ); + if let Some(addr) = request.policy.network_proxy.address.as_ref() { + let _ = writeln!(logger, "effective proxy: {}", addr.to_url()); let _ = writeln!( logger, "warning: proxy support on Windows is best-effort -- only scripts that use \ @@ -482,7 +475,7 @@ impl BaseContainerRunner { let supports_network_ingress = psec_version >= SecurityEnvironmentVersion::V1_1 && secenv::query_support(SecurityEnvironmentSupport::NetworkIngress); let process_security_environment_spec = build_psec_v1_security_environment_spec( - &request, + request, psec_version, supports_network_ingress, ); @@ -526,7 +519,7 @@ impl BaseContainerRunner { // Resolved via the shared helper so both Windows launch paths agree and // neither can pass a NULL cwd (see `working_directory`). let working_directory = - crate::process_container_common::working_directory::launch_working_directory(&request); + crate::process_container_common::working_directory::launch_working_directory(request); let _ = writeln!( logger, "working directory: {}", @@ -683,7 +676,7 @@ impl BaseContainerRunner { // Explicit variables are always isolated from the parent environment. // CreateProcessW must receive an explicit clean block or it would // inherit all wxc-exec process variables. - let env_block = build_child_env_block(&request).map_err(|error| { + let env_block = build_child_env_block(request).map_err(|error| { ScriptResponse::error(&format!( "failed to create a clean child environment: {error}" )) @@ -734,6 +727,12 @@ impl BaseContainerRunner { let msg = format!("captureDenials: failed to start learning-mode capture: {e}"); let _ = writeln!(logger, "Error: {msg}"); + log_base_network_policy_audit( + request, + &identity, + OperationStatus::Failure, + logger, + ); let failure_phase = if e.is_api_unavailable() { FailurePhase::BackendUnavailable } else { @@ -763,6 +762,12 @@ impl BaseContainerRunner { let msg = format!("failed to create the process security environment: {error}"); let _ = writeln!(logger, "Error: {msg}"); + log_base_network_policy_audit( + request, + &identity, + OperationStatus::Failure, + logger, + ); let failure_phase = if error.is_api_unavailable() { FailurePhase::BackendUnavailable } else { @@ -813,6 +818,12 @@ impl BaseContainerRunner { ); } let _ = writeln!(logger, "Error: {msg}"); + log_base_network_policy_audit( + request, + &identity, + OperationStatus::Failure, + logger, + ); let failure_phase = if primary.is_api_unavailable() || cleanup_error.as_ref().is_some_and( crate::learning_mode_windows::LearningModeError::is_api_unavailable, @@ -1056,42 +1067,7 @@ impl BaseContainerRunner { .map(|address| address.port as u64) .unwrap_or(0), ); - if logger.has_diagnostic_sink() { - let record = AuditEvent::new(AuditEventName::NetworkPolicyApplied) - .str("backend", ContainmentBackend::ProcessContainer.wire_name()) - .str("identity", sanitize_identity(&identity)) - .str( - "tier", - crate::process_container_common::fallback_detector::IsolationTier::BaseContainer.as_str(), - ) - .str( - "enforcement_mode", - crate::process_container_common::network_policy_helpers::CAPABILITIES_ENFORCEMENT_MODE, - ) - .str( - "default_policy", - crate::process_container_common::network_policy_helpers::audit_egress_default( - &request.policy, - ), - ) - .u64( - "proxy_port", - request - .policy - .network_proxy - .address - .as_ref() - .map(|address| address.port as u64) - .unwrap_or(0), - ) - .u64("firewall_rules_created", 0) - .bool("firewall_applied", false) - .str( - "status", - crate::mxc_common::audit::OperationStatus::Success.as_str(), - ); - logger.log_audit_event(&record); - } + log_base_network_policy_audit(request, &identity, OperationStatus::Success, logger); // Hand ownership to the caller via `BaseChild`, which performs // sandbox/proxy teardown after the child exits. `job` is always present @@ -1164,14 +1140,6 @@ impl SandboxBackend for BaseContainerRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { validate_required_child_env(request)?; validate_network_policy_support(request, self.network_policy_support())?; - if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { - return Err(ScriptResponse::rejected( - crate::mxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, - )); - } - crate::process_container_common::network_policy_helpers::reject_retired_network_policy( - &request.policy, - )?; if has_conflicting_proxy_identity(&request.policy) { return Err(ScriptResponse::rejected( "processContainer.network.allowedProxyPeer grants loopback access only to the \ @@ -2062,13 +2030,83 @@ mod tests { }; use crate::mxc_common::models::{ BaseProcessUiConfig, ClipboardPolicy, ContainerPolicy, NetworkAction, NetworkCidr, - NetworkPeer, NetworkPort, NetworkProtocol, NetworkRule, ProxyConfig, UiPolicy, + NetworkEgressPolicy, NetworkPeer, NetworkPort, NetworkProtocol, NetworkRule, ProxyAddress, + ProxyConfig, UiPolicy, }; use crate::mxc_common::ui_policy::EffectiveUiRestrictions; use crate::process_container_common::job_object::to_job_object_uilimit_mask; use crate::process_security_environment_spec::process_security_environment_layout as psec_layout; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn basecontainer_network_audit_records_proxyless_proxy_and_setup_failure() { + for (default, proxy_port, status, expected_default) in [ + (NetworkAction::Deny, None, OperationStatus::Success, "block"), + ( + NetworkAction::Allow, + None, + OperationStatus::Success, + "allow", + ), + ( + NetworkAction::Deny, + Some(8080), + OperationStatus::Success, + "block", + ), + ( + NetworkAction::Deny, + Some(8080), + OperationStatus::Failure, + "block", + ), + (NetworkAction::Deny, None, OperationStatus::Failure, "block"), + ] { + let mut request = ExecutionRequest::default(); + request.policy.network_egress = Some(NetworkEgressPolicy { + default, + ..Default::default() + }); + request.policy.network_proxy.address = + proxy_port.map(|port| ProxyAddress::new("127.0.0.1".to_string(), port)); + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("network-audit.log"); + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + logger.enable_file_sink(&path).expect("diagnostic sink"); + + log_base_network_policy_audit( + &request, + "", + status, + &mut logger, + ); + drop(logger); + + let output = std::fs::read_to_string(&path).expect("read audit"); + let (_, json) = output + .trim_end() + .split_once("] ") + .expect("timestamped audit record"); + let record: serde_json::Value = serde_json::from_str(json).expect("valid JSON"); + assert_eq!(output.lines().count(), 1); + assert_eq!( + record, + serde_json::json!({ + "event": "mxc.NetworkPolicyApplied", + "backend": "processcontainer", + "identity": "redacted", + "tier": "base-container", + "enforcement_mode": "capabilities", + "default_policy": expected_default, + "proxy_port": proxy_port.unwrap_or(0), + "firewall_rules_created": 0, + "firewall_applied": false, + "status": status.as_str(), + }) + ); + } + } + fn enumerate_request() -> ExecutionRequest { let mut request = ExecutionRequest::default(); request.policy.enumerate_paths = vec!["C:\\tools".to_string()]; @@ -2592,7 +2630,6 @@ mod tests { request.policy.runtime_network_proxy_specified = true; request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; request.env = Some(vec!["PATH=C:\\Windows".to_string()]); @@ -2947,7 +2984,6 @@ mod tests { request.policy.runtime_network_proxy_specified = runtime_proxy_specified; request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; request.policy.allowed_proxy_peer = Some("Contoso.Proxy_12345".to_string()); @@ -3250,7 +3286,6 @@ mod tests { let mut request = ExecutionRequest::default(); request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; assert!( @@ -3268,7 +3303,6 @@ mod tests { request.policy.runtime_network_proxy_specified = true; request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; assert!(BaseContainerRunner::can_backend_service_request(&request).can_service_request()); } @@ -3277,36 +3311,6 @@ mod tests { use crate::mxc_common::sandbox_process::SandboxBackend; - #[test] - fn validate_runner_rejects_allowed_hosts() { - let runner = BaseContainerRunner::new(); - let mut request = ExecutionRequest { - dry_run: true, - ..Default::default() - }; - request.policy.allowed_hosts = vec!["example.com".into()]; - - let err = runner - .validate(&request) - .expect_err("allowedHosts is not yet supported"); - assert!(err.error_message.contains("allowedHosts")); - } - - #[test] - fn validate_runner_rejects_blocked_hosts() { - let runner = BaseContainerRunner::new(); - let mut request = ExecutionRequest { - dry_run: true, - ..Default::default() - }; - request.policy.blocked_hosts = vec!["bad.example.com".into()]; - - let err = runner - .validate(&request) - .expect_err("blockedHosts is not yet supported"); - assert!(err.error_message.contains("blockedHosts")); - } - #[test] fn validate_runner_accepts_empty_policy() { let runner = BaseContainerRunner::new(); diff --git a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs index e0dd25702..322cfcf5c 100644 --- a/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs +++ b/src/mxc-sdk/src/backends/process_container/common/dispatcher.rs @@ -1132,7 +1132,6 @@ mod tests { let mut policy = empty_policy(); policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; let req = test_request(policy); diff --git a/src/mxc-sdk/src/backends/process_container/common/mod.rs b/src/mxc-sdk/src/backends/process_container/common/mod.rs index 2ea755ed8..6127f6f48 100644 --- a/src/mxc-sdk/src/backends/process_container/common/mod.rs +++ b/src/mxc-sdk/src/backends/process_container/common/mod.rs @@ -34,8 +34,6 @@ pub mod launch_diagnostics; #[cfg(target_os = "windows")] mod native_capture; #[cfg(target_os = "windows")] -pub mod network_manager; -#[cfg(target_os = "windows")] mod network_policy_helpers; #[cfg(target_os = "windows")] pub mod probe; diff --git a/src/mxc-sdk/src/backends/process_container/common/network_manager.rs b/src/mxc-sdk/src/backends/process_container/common/network_manager.rs deleted file mode 100644 index 67876c96d..000000000 --- a/src/mxc-sdk/src/backends/process_container/common/network_manager.rs +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use windows::Win32::Security::PSID; - -use crate::mxc_common::error::WxcError; -use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{ContainerPolicy, ProxyAddress}; -use crate::process_container_common::network_policy_helpers::reject_retired_network_policy; -use crate::process_container_common::proxy_coordinator::ProxyCoordinator; - -#[derive(Default)] -pub struct NetworkManager { - proxy_coordinator: ProxyCoordinator, -} - -impl NetworkManager { - pub fn new() -> Self { - Self { - proxy_coordinator: ProxyCoordinator::new(), - } - } - - pub fn proxy_address(&self) -> Option<&ProxyAddress> { - self.proxy_coordinator.address() - } - - /// Configure the supported runtime proxy. Directional network access on - /// this tier is enforced by AppContainer capabilities, not firewall rules. - pub fn start( - &mut self, - principal_id: &str, - container_name: &str, - policy: &ContainerPolicy, - script_sid: PSID, - logger: &mut Logger, - ) -> Result<(), WxcError> { - if !policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty() { - return Err(WxcError::Validation( - "network.allowedHosts / network.blockedHosts are retired; \ - use network.egress and network.ingress" - .into(), - )); - } - reject_retired_network_policy(policy) - .map_err(|error| WxcError::Validation(error.error_message))?; - - if policy.network_proxy.is_enabled() { - self.proxy_coordinator.start( - &policy.network_proxy, - container_name, - principal_id, - script_sid, - logger, - )?; - } - - Ok(()) - } - - /// Stop the proxy, including when the caller preserves filesystem policy. - pub fn stop_all(&mut self, logger: &mut Logger) -> bool { - self.proxy_coordinator.stop(logger) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::mxc_common::logger::Mode; - use crate::mxc_common::models::{ - NetworkAction, NetworkEgressPolicy, NetworkEnforcementMode, NetworkPolicy, - }; - - #[test] - fn proxyless_policy_has_no_network_resources_to_stop() { - let mut logger = Logger::new(Mode::Buffer); - let mut manager = NetworkManager::new(); - let policy = ContainerPolicy { - network_egress: Some(NetworkEgressPolicy { - default: NetworkAction::Allow, - ..Default::default() - }), - ..Default::default() - }; - manager - .start( - "principal", - "container", - &policy, - PSID::default(), - &mut logger, - ) - .unwrap(); - - assert!(manager.proxy_address().is_none()); - assert!(!manager.stop_all(&mut logger)); - } - - #[test] - fn direct_retired_policy_is_rejected_before_proxy_setup() { - let mut logger = Logger::new(Mode::Buffer); - let mut manager = NetworkManager::new(); - let mut policy = ContainerPolicy::default(); - policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".into(), 8080)); - policy.allowed_hosts.push("example.com".into()); - assert!(matches!( - manager.start("principal", "container", &policy, PSID::default(), &mut logger), - Err(WxcError::Validation(message)) if message.contains("allowedHosts") - )); - - policy.allowed_hosts.clear(); - policy.blocked_hosts.push("example.com".into()); - assert!(matches!( - manager.start("principal", "container", &policy, PSID::default(), &mut logger), - Err(WxcError::Validation(message)) if message.contains("blockedHosts") - )); - - policy.blocked_hosts.clear(); - policy.default_network_policy = NetworkPolicy::Allow; - assert!(matches!( - manager.start("principal", "container", &policy, PSID::default(), &mut logger), - Err(WxcError::Validation(message)) if message.contains("defaultPolicy") - )); - - policy.default_network_policy = NetworkPolicy::Block; - policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; - assert!(matches!( - manager.start("principal", "container", &policy, PSID::default(), &mut logger), - Err(WxcError::Validation(message)) if message.contains("enforcementMode") - )); - - policy.network_enforcement_mode = NetworkEnforcementMode::Capabilities; - policy.allow_local_network = true; - assert!(matches!( - manager.start("principal", "container", &policy, PSID::default(), &mut logger), - Err(WxcError::Validation(message)) if message.contains("allowLocalNetwork") - )); - assert!(manager.proxy_address().is_none()); - } -} diff --git a/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs b/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs index 368112459..4fdd00bd2 100644 --- a/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs +++ b/src/mxc-sdk/src/backends/process_container/common/network_policy_helpers.rs @@ -3,9 +3,8 @@ //! Shared ProcessContainer network-policy helpers. -use crate::mxc_common::models::{ - ContainerPolicy, NetworkAction, NetworkEnforcementMode, NetworkPolicy, ScriptResponse, -}; +use crate::mxc_common::audit::{AuditEvent, AuditEventName, OperationStatus}; +use crate::mxc_common::models::{ContainerPolicy, ContainmentBackend, NetworkAction, ProxyAddress}; pub(crate) const INTERNET_CLIENT_CAPABILITY: &str = "internetClient"; pub(crate) const PRIVATE_NETWORK_CAPABILITY: &str = "privateNetworkClientServer"; @@ -35,24 +34,26 @@ pub(crate) fn audit_egress_default(policy: &ContainerPolicy) -> &'static str { } } -pub(crate) fn reject_retired_network_policy( +pub(crate) fn network_policy_applied_record( policy: &ContainerPolicy, -) -> Result<(), ScriptResponse> { - let field = if policy.default_network_policy != NetworkPolicy::Block { - Some("network.defaultPolicy") - } else if policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities { - Some("network.enforcementMode") - } else if policy.allow_local_network { - Some("network.allowLocalNetwork") - } else { - None - }; - if let Some(field) = field { - return Err(ScriptResponse::rejected(&format!( - "{field} is retired; use network.egress and network.ingress" - ))); - } - Ok(()) + identity: &str, + tier: &str, + proxy_address: Option<&ProxyAddress>, + status: OperationStatus, +) -> AuditEvent { + AuditEvent::new(AuditEventName::NetworkPolicyApplied) + .str("backend", ContainmentBackend::ProcessContainer.wire_name()) + .str("identity", identity) + .str("tier", tier) + .str("enforcement_mode", CAPABILITIES_ENFORCEMENT_MODE) + .str("default_policy", audit_egress_default(policy)) + .u64( + "proxy_port", + proxy_address.map_or(0, |address| address.port as u64), + ) + .u64("firewall_rules_created", 0) + .bool("firewall_applied", false) + .str("status", status.as_str()) } pub(crate) fn ensure_capability(capabilities: &mut Vec, capability: &str) { @@ -168,32 +169,6 @@ mod tests { ); } - #[test] - fn directly_built_requests_cannot_activate_retired_network_fields() { - let mut policy = ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - }; - assert!(reject_retired_network_policy(&policy) - .unwrap_err() - .error_message - .contains("defaultPolicy")); - - policy.default_network_policy = NetworkPolicy::Block; - policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; - assert!(reject_retired_network_policy(&policy) - .unwrap_err() - .error_message - .contains("enforcementMode")); - - policy.network_enforcement_mode = NetworkEnforcementMode::Capabilities; - policy.allow_local_network = true; - assert!(reject_retired_network_policy(&policy) - .unwrap_err() - .error_message - .contains("allowLocalNetwork")); - } - #[test] fn audit_default_describes_directional_egress_not_allowed_exceptions() { let mut policy = ContainerPolicy::default(); diff --git a/src/mxc-sdk/src/backends/process_container/common/proxy_coordinator.rs b/src/mxc-sdk/src/backends/process_container/common/proxy_coordinator.rs index 15b3845bd..75a274c94 100644 --- a/src/mxc-sdk/src/backends/process_container/common/proxy_coordinator.rs +++ b/src/mxc-sdk/src/backends/process_container/common/proxy_coordinator.rs @@ -8,9 +8,7 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::WAIT_OBJECT_0; use windows::Win32::Security::PSID; use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress}; -use windows::Win32::System::Threading::{ - CreateEventW, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_SYNCHRONIZE, -}; +use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; use windows::Win32::UI::Shell::{ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW}; use crate::mxc_common::error::WxcError; @@ -205,22 +203,13 @@ fn poll_for_ready_file( } /// Manages the network proxy lifecycle for sandboxed AppContainer workloads. -/// -/// Handles two proxy modes: -/// - **External proxy**: user provides a `localhost` port in the config -/// - **Builtin test server**: wxc launches `wxc-test-proxy.exe` itself to get -/// an OS-assigned port (for integration testing only) -/// -/// In both cases, WinHTTP proxy policy is set by an elevated +/// The caller manages the proxy; WinHTTP proxy policy is set by an elevated /// `winhttp-proxy-shim` process launched via UAC. pub struct ProxyCoordinator { - proxy_address: Option, + proxy_address: Option, shim_process_handle: Option, shim_cleanup_event: Option, shim_ready_file_path: Option, - test_proxy_handle: Option, - test_proxy_cleanup_event: Option, - test_proxy_ready_file_path: Option, loopback_container_name: Option, } @@ -258,9 +247,6 @@ impl ProxyCoordinator { shim_process_handle: None, shim_cleanup_event: None, shim_ready_file_path: None, - test_proxy_handle: None, - test_proxy_cleanup_event: None, - test_proxy_ready_file_path: None, loopback_container_name: None, } } @@ -271,15 +257,14 @@ impl ProxyCoordinator { } /// Returns the proxy address (if active). - pub fn address(&self) -> Option<&crate::mxc_common::models::ProxyAddress> { + pub fn address(&self) -> Option<&ProxyAddress> { self.proxy_address.as_ref() } /// Activate the proxy based on the given config. /// - /// If `builtin_test_server` is set, launches `wxc-test-proxy.exe` first to - /// obtain a port. Then sets up loopback exemption and WinHTTP proxy policy - /// via the elevated shim. + /// Sets up loopback exemption and WinHTTP proxy policy via the elevated + /// shim for a caller-managed proxy address. pub fn start( &mut self, proxy_config: &ProxyConfig, @@ -294,12 +279,7 @@ impl ProxyCoordinator { )); } - let address = if proxy_config.builtin_test_server { - let port = self.launch_test_proxy(logger)?; - ProxyAddress::new("127.0.0.1".to_string(), port) - } else if let Some(ref addr) = proxy_config.address { - addr.clone() - } else { + let Some(address) = proxy_config.address.clone() else { return Ok(()); }; @@ -325,85 +305,6 @@ impl ProxyCoordinator { Ok(()) } - /// Launch `wxc-test-proxy.exe` and read its port from the ready file. - pub fn launch_test_proxy(&mut self, logger: &mut Logger) -> Result { - logger.log_line( - "WARNING: Starting builtin test proxy — this is for integration testing only, \ - NOT for production use.", - ); - - let unique_id = generate_unique_id(); - let ready_file_path = - std::env::temp_dir().join(format!("wxc-test-proxy-ready-{}.tmp", unique_id)); - let event_name = format!("Local\\wxc-test-proxy-{}", unique_id); - let event_name_wide = string_util::to_wide(&event_name); - - let event_handle = - unsafe { CreateEventW(None, true, false, PCWSTR(event_name_wide.as_ptr())) }.map_err( - |err| { - WxcError::NetworkProxy(format!( - "Failed to create test proxy cleanup event: {}", - err - )) - }, - )?; - - self.test_proxy_cleanup_event = Some(OwnedHandle::new(event_handle)); - self.test_proxy_ready_file_path = Some(ready_file_path.clone()); - - let proxy_exe = resolve_sibling_binary("wxc-test-proxy.exe")?; - - let mut child = std::process::Command::new(&proxy_exe) - .arg("--ready-file") - .arg(&ready_file_path) - .arg("--cleanup-event") - .arg(&event_name) - .arg("--parent-pid") - .arg(std::process::id().to_string()) - .stderr(std::process::Stdio::inherit()) - .stdout(std::process::Stdio::null()) - .stdin(std::process::Stdio::null()) - .spawn() - .map_err(|err| { - WxcError::NetworkProxy(format!("Failed to launch wxc-test-proxy.exe: {}", err)) - })?; - - let child_pid = child.id(); - let process_handle = match unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, child_pid) } { - Ok(handle) => handle, - Err(err) => { - let _ = child.kill(); - return Err(WxcError::NetworkProxy(format!( - "Failed to open handle for test proxy process: {}", - err - ))); - } - }; - self.test_proxy_handle = Some(OwnedHandle::new(process_handle)); - - poll_for_ready_file( - &ready_file_path, - self.test_proxy_handle.as_ref().unwrap(), - 15, - logger, - "wxc-test-proxy", - )?; - - let content = std::fs::read_to_string(&ready_file_path).map_err(|err| { - WxcError::NetworkProxy(format!("Failed to read test proxy ready file: {}", err)) - })?; - - let port: u16 = content.trim().parse().map_err(|err| { - WxcError::NetworkProxy(format!( - "Invalid port in test proxy ready file '{}': {}", - content.trim(), - err - )) - })?; - - Ok(port) - } - /// Launch the elevated winhttp-proxy-shim to set the per-AppContainer /// WinHTTP proxy policy. fn launch_shim(&mut self, principal_id: &str, logger: &mut Logger) -> Result<(), WxcError> { @@ -452,7 +353,7 @@ impl ProxyCoordinator { ) } - /// Stop the proxy: signal shim and test proxy cleanup, remove loopback exemption. + /// Stop the shim and remove the loopback exemption; the caller owns the proxy. pub fn stop(&mut self, logger: &mut Logger) -> bool { let was_active = self.is_active(); signal_process_cleanup( @@ -461,20 +362,11 @@ impl ProxyCoordinator { "winhttp-proxy-shim", logger, ); - signal_process_cleanup( - self.test_proxy_cleanup_event.take(), - self.test_proxy_handle.take(), - "wxc-test-proxy", - logger, - ); self.proxy_address = None; if let Some(path) = self.shim_ready_file_path.take() { let _ = std::fs::remove_file(&path); } - if let Some(path) = self.test_proxy_ready_file_path.take() { - let _ = std::fs::remove_file(&path); - } if let Some(container_name) = self.loopback_container_name.take() { remove_loopback_exemption(&container_name); } @@ -520,6 +412,23 @@ mod tests { assert!(!mgr.is_active()); } + #[test] + fn disabled_proxy_does_not_start_shim() { + let mut mgr = ProxyCoordinator::new(); + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + mgr.start( + &ProxyConfig::default(), + "container", + "principal", + PSID::default(), + &mut logger, + ) + .unwrap(); + assert!(mgr.address().is_none()); + assert!(mgr.shim_process_handle.is_none()); + assert!(mgr.loopback_container_name.is_none()); + } + #[test] fn test_generate_unique_id() { let id = generate_unique_id(); diff --git a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs index 11846b026..34f128fce 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/profile_builder.rs @@ -1044,7 +1044,6 @@ mod tests { r.policy.network_egress = Some(egress(NetworkAction::Deny)); r.policy.network_proxy = crate::mxc_common::models::ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".into(), 9091)), - ..Default::default() }; let p = build_profile(&r).unwrap(); assert!(p.contains("(allow network-outbound (remote ip \"localhost:9091\"))")); diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs index 35895a6ef..d1f721d6a 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_policy.rs @@ -11,9 +11,7 @@ //! `ScriptResponse`. use crate::mxc_common::host_is_canonical_loopback; -use crate::mxc_common::models::{ - ContainerPolicy, ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, -}; +use crate::mxc_common::models::{ContainerPolicy, ExecutionRequest, NetworkAction}; /// Effective GUI posture: `seatbelt.guiAccess` only means anything when the UI /// policy leaves UI enabled, since every GUI grant is emitted alongside the @@ -75,24 +73,6 @@ pub fn validate_seatbelt_network_policy(policy: &ContainerPolicy) -> Result<(), let proxy_enabled = policy.network_proxy.is_enabled(); let outbound_allowed = egress_allowed(policy); - if policy.default_network_policy != NetworkPolicy::Block - || policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities - || policy.allow_local_network - || !policy.allowed_hosts.is_empty() - || !policy.blocked_hosts.is_empty() - { - return Err("Seatbelt: retired network fields are not supported; use \ - network.egress and network.ingress" - .to_string()); - } - if policy.network_proxy.builtin_test_server { - return Err( - "Seatbelt: network.proxy.builtinTestServer is retired; supply a caller-managed \ - loopback proxy through runtimeConfig.networkProxy" - .to_string(), - ); - } - // A remote proxy can't be expressed as a reachability rule. if !outbound_allowed && policy @@ -176,7 +156,6 @@ mod tests { host.to_string(), 8080, )), - builtin_test_server: false, } } @@ -193,37 +172,6 @@ mod tests { assert!(msg.contains("network.egress.default='deny'"), "got: {msg}"); } - #[test] - fn rejects_retired_firewall_enforcement_mode() { - for mode in [ - NetworkEnforcementMode::Firewall, - NetworkEnforcementMode::Both, - ] { - let mut p = policy(); - p.network_proxy = proxy("127.0.0.1"); - p.network_enforcement_mode = mode.clone(); - - let msg = validate_seatbelt_network_policy(&p).unwrap_err(); - assert!( - msg.contains("retired network fields"), - "{mode:?} got: {msg}" - ); - } - } - - #[test] - fn rejects_retired_builtin_test_proxy() { - let mut p = policy(); - p.network_proxy = ProxyConfig { - address: None, - builtin_test_server: true, - }; - - assert!(validate_seatbelt_network_policy(&p) - .unwrap_err() - .contains("builtinTestServer")); - } - /// The guard compared unbracketed literals only, so `http://[::1]` — the /// documented IPv6 form — was misread as remote and rejected. #[test] @@ -267,50 +215,6 @@ mod tests { } } - #[test] - fn rejects_retired_allowed_hosts() { - let mut p = policy(); - p.allowed_hosts = vec!["api.github.com".to_string()]; - - let msg = validate_seatbelt_network_policy(&p).unwrap_err(); - assert!(msg.contains("retired network fields"), "got: {msg}"); - } - - #[test] - fn rejects_retired_allowed_hosts_with_builtin_test_proxy() { - let mut p = policy(); - p.allowed_hosts = vec!["api.github.com".to_string()]; - p.network_proxy = ProxyConfig { - address: None, - builtin_test_server: true, - }; - - assert!(validate_seatbelt_network_policy(&p) - .unwrap_err() - .contains("retired network fields")); - } - - #[test] - fn rejects_allowed_hosts_with_external_proxy() { - let mut p = policy(); - p.allowed_hosts = vec!["api.github.com".to_string()]; - p.network_proxy = proxy("127.0.0.1"); - - let msg = validate_seatbelt_network_policy(&p).unwrap_err(); - assert!(msg.contains("retired network fields"), "got: {msg}"); - } - - #[test] - fn seatbelt_rejects_retired_allow_default_and_host_list() { - let mut p = policy(); - p.default_network_policy = NetworkPolicy::Allow; - p.allowed_hosts = vec!["api.github.com".to_string()]; - - assert!(validate_seatbelt_network_policy(&p) - .unwrap_err() - .contains("retired network fields")); - } - /// `guiAccess` with a `SeatbeltConfig` and the given `ui.disable`. fn gui_request(gui_access: bool, ui_disabled: bool) -> ExecutionRequest { let mut r = ExecutionRequest::default(); diff --git a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs index 774c83585..b6f1e8dca 100644 --- a/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs +++ b/src/mxc-sdk/src/backends/seatbelt/common/seatbelt_runner.rs @@ -37,7 +37,6 @@ use crate::mxc_common::sandbox_process::{ spawn_discard, take_boxed_read, take_boxed_write, wait_with_timeout, NativeStdio, PtySize, SandboxBackend, SandboxProcess, StdioMode, StreamCloser, WaitError, }; -use crate::mxc_common::unix_proxy_coordinator::UnixProxyCoordinator; use crate::mxc_common::validator::{ validate_common, validate_network_policy_support, NetworkPolicySupport, }; @@ -295,31 +294,20 @@ impl SandboxBackend for SeatbeltScriptRunner { validate_common(request)?; self.validate(request)?; - // Start the cooperative network proxy (if configured) before building - // the profile and launching the child: the profile's proxy-reachability - // rule is scoped to the proxy's *resolved* address, and the child needs that address injected as - // HTTP_PROXY / HTTPS_PROXY. macOS has no WinHTTP-style OS proxy policy, + // The caller manages the proxy. Scope the profile's reachability rule + // to its address and inject HTTP_PROXY / HTTPS_PROXY. macOS has no + // WinHTTP-style OS proxy policy, // so — like the Bubblewrap backend — enforcement is cooperative: // well-behaved HTTP clients honor the env vars; raw-socket clients // bypass them. - let mut proxy = UnixProxyCoordinator::new(); - if request.policy.network_proxy.is_enabled() { - proxy - .start( - &request.policy.network_proxy, - "127.0.0.1", - &[], - &[], - crate::mxc_common::models::NetworkPolicy::Block, - logger, - ) - .map_err(|err| { - error_response(format!("Seatbelt: failed to start network proxy: {err}")) - })?; + let proxy_address = request.policy.network_proxy.address.as_ref(); + if let Some(address) = proxy_address { + logger.log_line(&format!( + "Unix network proxy active: {}", + crate::mxc_common::proxy_env::redact_proxy_url(&address.to_url()) + )); } - // Build the Seatbelt profile now that the proxy address is resolved, so - // the reachability rule can be scoped to the proxy's exact host + port. - let profile = build_profile_with_proxy(request, proxy.address()).map_err(error_response)?; + let profile = build_profile_with_proxy(request, proxy_address).map_err(error_response)?; log_generated_profile(&profile, logger); // Determine launch method + GUI access from the seatbelt config. @@ -334,8 +322,10 @@ impl SandboxBackend for SeatbeltScriptRunner { let gui_access = crate::seatbelt_common::seatbelt_policy::gui_access_effective(request); match launch_method { - LaunchMethod::Exec => spawn_exec(&profile, request, gui_access, stdio, logger, proxy), - LaunchMethod::Open => spawn_open(&profile, request, stdio, logger, proxy), + LaunchMethod::Exec => { + spawn_exec(&profile, request, gui_access, stdio, logger, proxy_address) + } + LaunchMethod::Open => spawn_open(&profile, request, stdio, logger, proxy_address), } } } @@ -353,7 +343,7 @@ fn spawn_exec( gui_access: bool, stdio: StdioMode, logger: &mut Logger, - proxy: UnixProxyCoordinator, + proxy_address: Option<&ProxyAddress>, ) -> Result, ScriptResponse> { if gui_access && stdio != StdioMode::Inherit { let mode = if stdio == StdioMode::Pipes { @@ -409,7 +399,7 @@ fn spawn_exec( } }; let home_dir = resolved_cwd.as_ref().map(|_| cwd.as_str()); - apply_clean_environment(&mut command, request, proxy.address(), home_dir); + apply_clean_environment(&mut command, request, proxy_address, home_dir); // Also export `PWD` so the child's `getcwd()` uses its // fast `$PWD` path (a single stat) instead of walking parent directories @@ -484,7 +474,6 @@ fn spawn_exec( group: new_session || new_group || stdio != StdioMode::Inherit, session_id, cleanup: Vec::new(), - proxy, })) } @@ -498,7 +487,7 @@ fn spawn_open( request: &ExecutionRequest, stdio: StdioMode, logger: &mut Logger, - proxy: UnixProxyCoordinator, + proxy_address: Option<&ProxyAddress>, ) -> Result, ScriptResponse> { if stdio != StdioMode::Inherit { let mode = if stdio == StdioMode::Pipes { @@ -552,7 +541,7 @@ fn spawn_open( // active its HTTP_PROXY/HTTPS_PROXY vars are injected and caller-supplied // proxy vars stripped (see `resolve_environment`). let mut env_exports = String::new(); - for (key, value) in resolve_environment(request, proxy.address(), home_dir.as_deref()) { + for (key, value) in resolve_environment(request, proxy_address, home_dir.as_deref()) { // Validate key is a safe shell identifier to prevent injection. if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || key.is_empty() @@ -642,7 +631,6 @@ fn spawn_open( group: false, session_id: None, cleanup: vec![profile_path, helper_path, command_path], - proxy, })) } @@ -677,23 +665,12 @@ struct SeatbeltSandboxProcess { session_id: Option, /// Temp files to remove once the child exits (Open mode); empty otherwise. cleanup: Vec, - /// The per-run cooperative network proxy. Inactive (a no-op on teardown) - /// unless `network.proxy` was configured; stopped once the child exits. - proxy: UnixProxyCoordinator, } impl SeatbeltSandboxProcess { - /// Tear down per-run state once the child has exited: stop the cooperative - /// network proxy (idempotent; a no-op when it was never started) and remove - /// the Open-mode temp files (profile / helper / `.command`). Safe to call - /// from both `wait()` and `drop` — the proxy stop is idempotent and the - /// temp-file list is drained. + /// Remove Open-mode temp files (profile / helper / `.command`) after the + /// child exits. Safe to call from both `wait()` and `drop`. fn run_cleanup(&mut self) { - // Silent buffer logger: teardown may run during `drop` (possibly on an - // unwinding path), and the coordinator's own `Drop` is likewise silent. - let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); - self.proxy.stop(&mut logger); - if self.cleanup.is_empty() { return; } @@ -1356,7 +1333,7 @@ mod tests { true, StdioMode::Pty(PtySize::default()), &mut logger, - UnixProxyCoordinator::new(), + None, ) .err() .expect("guiAccess PTY must be rejected"); @@ -1371,7 +1348,7 @@ mod tests { &base_request(), StdioMode::Pty(PtySize::default()), &mut logger, - UnixProxyCoordinator::new(), + None, ) .err() .expect("open PTY must be rejected"); @@ -1473,16 +1450,6 @@ mod tests { assert!(profile.contains("mxc-profile-log-probe"), "got: {profile}"); } - #[test] - fn rejects_retired_blocked_hosts_bypassing_the_parser() { - let mut request = base_request(); - request.policy.blocked_hosts = vec!["evil.example.com".into()]; - let runner = SeatbeltScriptRunner::new(); - let response = runner.validate(&request).unwrap_err(); - assert_eq!(response.exit_code, -1); - assert!(response.error_message.contains("retired network fields")); - } - /// Directly constructed requests must receive the same rejection as parsed /// requests, before the profile or any proxy is created. #[test] @@ -1506,27 +1473,6 @@ mod tests { ); } - #[test] - fn rejects_allowed_hosts_under_deny_bypassing_the_parser() { - let mut request = base_request(); - request.policy.allowed_hosts = vec!["example.com".into()]; - let runner = SeatbeltScriptRunner::new(); - let err = runner.validate(&request).unwrap_err(); - assert!( - err.error_message.contains("retired network fields"), - "{err:?}" - ); - } - - #[test] - fn rejects_retired_builtin_test_proxy() { - let mut request = base_request(); - request.policy.network_proxy.builtin_test_server = true; - let runner = SeatbeltScriptRunner::new(); - let err = runner.validate(&request).unwrap_err(); - assert!(err.error_message.contains("builtinTestServer"), "{err:?}"); - } - #[test] fn rejects_remote_proxy_under_deny_bypassing_the_parser() { let mut request = base_request(); @@ -1538,7 +1484,8 @@ mod tests { let runner = SeatbeltScriptRunner::new(); let err = runner.validate(&request).unwrap_err(); assert!( - err.error_message.contains("remote network.proxy"), + err.error_message + .contains("runtimeConfig.networkProxy must name a loopback endpoint"), "{err:?}" ); } @@ -1887,7 +1834,7 @@ mod tests { /// request. #[test] fn home_follows_the_directory_the_child_starts_in() { - let mut request = base_request(); + let request = base_request(); let pairs = resolve_environment(&request, None, Some("/Users/someone/work")); assert_eq!(env_value(&pairs, "HOME"), Some("/Users/someone/work")); } diff --git a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/policy.rs b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/policy.rs index aa05523c9..d4db9825b 100644 --- a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/policy.rs +++ b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/policy.rs @@ -10,9 +10,7 @@ use std::path::Path; -use crate::mxc_common::models::{ - ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, -}; +use crate::mxc_common::models::{ExecutionRequest, NetworkAction}; use crate::windows_sandbox_lifecycle::error::OneShotError; use crate::windows_sandbox_lifecycle::vm::MappedFolder; @@ -37,29 +35,12 @@ pub(crate) fn plan_policy(request: &ExecutionRequest) -> Result Result<(), OneShotError> { let policy = &request.policy; - if !policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty() { - return Err(OneShotError::Policy( - "per-host network filtering (allowedHosts/blockedHosts) is not supported by the \ - Windows Sandbox backend; the guest agent enforces all-or-nothing network isolation" - .to_string(), - )); - } if policy.network_proxy.is_enabled() { return Err(OneShotError::Policy( "a network proxy is not supported by the Windows Sandbox backend".to_string(), )); } - if policy.default_network_policy != NetworkPolicy::Block - || policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities - || policy.allow_local_network - { - return Err(OneShotError::Policy( - "retired network fields are not supported by the Windows Sandbox backend; use \ - network.egress and network.ingress" - .to_string(), - )); - } if policy.network_mode_specified || policy.network_egress.as_ref().is_some_and(|egress| { egress.default == NetworkAction::Allow @@ -364,7 +345,7 @@ mod tests { fn explicitly_supplied_network_posture_is_not_silently_ignored() { let err = plan_policy(&request_with(ContainerPolicy { network_mode_specified: true, - network_egress: Some(NetworkEgressPolicy::default()), + network_egress: Some(crate::mxc_common::models::NetworkEgressPolicy::default()), ..Default::default() })) .unwrap_err(); @@ -374,7 +355,7 @@ mod tests { #[test] fn allow_network_rejected() { let err = plan_policy(&request_with(ContainerPolicy { - network_egress: Some(NetworkEgressPolicy { + network_egress: Some(crate::mxc_common::models::NetworkEgressPolicy { default: NetworkAction::Allow, ..Default::default() }), @@ -384,36 +365,6 @@ mod tests { assert_policy_err_contains(err, "directional network policy"); } - #[test] - fn retired_outbound_default_is_rejected() { - let err = plan_policy(&request_with(ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - })) - .unwrap_err(); - assert_policy_err_contains(err, "retired network fields"); - } - - #[test] - fn allowed_hosts_rejected() { - let err = plan_policy(&request_with(ContainerPolicy { - allowed_hosts: vec!["example.com".to_string()], - ..Default::default() - })) - .unwrap_err(); - assert_policy_err_contains(err, "per-host network filtering"); - } - - #[test] - fn blocked_hosts_rejected() { - let err = plan_policy(&request_with(ContainerPolicy { - blocked_hosts: vec!["evil.com".to_string()], - ..Default::default() - })) - .unwrap_err(); - assert_policy_err_contains(err, "per-host network filtering"); - } - #[test] fn direct_egress_allow_rule_rejected_without_presence_flag() { assert_rejects_direct_network(ContainerPolicy { @@ -473,7 +424,6 @@ mod tests { let err = plan_policy(&request_with(ContainerPolicy { network_proxy: ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }, ..Default::default() })) diff --git a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs index 94bdd46d0..8ca669f7d 100644 --- a/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs +++ b/src/mxc-sdk/src/backends/windows_sandbox/lifecycle/state_aware.rs @@ -223,13 +223,8 @@ fn reject_post_provision_policy(request: &ExecutionRequest) -> Result<(), MxcErr let p = &request.policy; if !p.readwrite_paths.is_empty() || !p.readonly_paths.is_empty() + || !p.enumerate_paths.is_empty() || !p.denied_paths.is_empty() - || !p.allowed_hosts.is_empty() - || !p.blocked_hosts.is_empty() - || p.default_network_policy != crate::mxc_common::models::NetworkPolicy::Block - || p.network_enforcement_mode - != crate::mxc_common::models::NetworkEnforcementMode::Capabilities - || p.allow_local_network || p.network_proxy.is_enabled() || p.network_mode_specified || p.network_egress.is_some() @@ -1067,7 +1062,7 @@ impl StatefulSandboxBackend for WindowsSandboxRunner { #[cfg(test)] mod tests { use super::*; - use crate::mxc_common::models::{ContainerPolicy, NetworkEgressPolicy, NetworkPolicy}; + use crate::mxc_common::models::{ContainerPolicy, NetworkEgressPolicy}; use crate::mxc_common::mxc_error::MxcErrorCode; /// A `Piped` exec is refused before the backend looks for the daemon. @@ -1311,6 +1306,19 @@ mod tests { assert_eq!(err.code, MxcErrorCode::PolicyValidation); } + #[test] + fn reject_post_provision_policy_rejects_enumerate_paths() { + let req = ExecutionRequest { + policy: ContainerPolicy { + enumerate_paths: vec!["C:\\work".into()], + ..Default::default() + }, + ..Default::default() + }; + let err = reject_post_provision_policy(&req).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + } + #[test] fn map_policy_error_maps_policy_to_validation() { let err = map_policy_error(OneShotError::Policy("nope".into())); @@ -1322,8 +1330,10 @@ mod tests { let backend = WindowsSandboxRunner::new(); let req = ExecutionRequest { policy: ContainerPolicy { - allowed_hosts: vec!["example.com".into()], - default_network_policy: NetworkPolicy::Block, + network_egress: Some(NetworkEgressPolicy { + default: crate::mxc_common::models::NetworkAction::Allow, + ..Default::default() + }), ..Default::default() }, ..Default::default() @@ -1901,22 +1911,17 @@ mod tests { let backend = WindowsSandboxRunner::new(); let bad_req = ExecutionRequest { policy: ContainerPolicy { - // A network proxy enabled with no proxy spec triggers a - // policy error in plan_policy. - allowed_hosts: vec!["nonsense::not-a-host".into()], - default_network_policy: NetworkPolicy::Block, + enumerate_paths: vec!["C:\\work".into()], ..Default::default() }, ..Default::default() }; - // We cannot easily produce a policy error via plan_policy from outside - // the policy module without coupling to its internals; instead assert - // the default-valued request passes and skip the negative case here. - // (Pure-helper coverage already lives in policy module tests.) + let err = backend.validate_provision(&bad_req, None).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + assert!(err.message.contains("enumeratePaths")); backend .validate_provision(&ExecutionRequest::default(), None) .expect("default request must pass validate_provision"); - let _ = bad_req; // suppress unused warning; intentional placeholder. } #[test] diff --git a/src/mxc-sdk/src/backends/wslc/common/policy.rs b/src/mxc-sdk/src/backends/wslc/common/policy.rs index 156a2f5b9..6e6630424 100644 --- a/src/mxc-sdk/src/backends/wslc/common/policy.rs +++ b/src/mxc-sdk/src/backends/wslc/common/policy.rs @@ -24,12 +24,9 @@ //! //! Checks run filesystem → ui → network, so a request that trips several gets a //! stable message rather than one that depends on field order (asserted by -//! tests). [`reject_ui_policy`] and [`reject_retired_network_fields`] apply -//! to both one-shot and state-aware validation. +//! tests). [`reject_ui_policy`] applies to both one-shot and state-aware validation. -use crate::mxc_common::models::{ - ExecutionRequest, NetworkAction, NetworkEnforcementMode, NetworkPolicy, -}; +use crate::mxc_common::models::{ExecutionRequest, NetworkAction}; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::validator::NetworkPolicySupport; @@ -87,9 +84,6 @@ pub(crate) fn network_is_isolated(request: &ExecutionRequest) -> bool { /// connectivity; BRIDGED cannot promise either inbound or host-loopback filtering. pub(crate) fn validate_directional_network(request: &ExecutionRequest) -> Result<(), MxcError> { let policy = &request.policy; - if policy.network_egress.is_none() && policy.network_ingress.is_none() { - return Ok(()); - } if policy .network_egress .as_ref() @@ -137,11 +131,10 @@ pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<() ) .map_err(MxcError::policy_validation)?; reject_ui_policy(request)?; - reject_retired_network_fields(request)?; - validate_directional_network(request)?; if request.policy.network_proxy.is_enabled() { return Err(MxcError::policy_validation(ERR_PROXY_AT_PROVISION)); } + validate_directional_network(request)?; Ok(()) } @@ -151,7 +144,6 @@ pub(crate) fn validate_provision_policy(request: &ExecutionRequest) -> Result<() pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Result<(), MxcError> { reject_filesystem_policy(request)?; reject_ui_policy(request)?; - reject_retired_network_fields(request)?; reject_post_provision_network_mode(request)?; if request.policy.network_proxy.is_enabled() { return Err(MxcError::policy_validation(ERR_PROXY_AT_PHASE)); @@ -165,7 +157,6 @@ pub(crate) fn validate_post_provision_policy(request: &ExecutionRequest) -> Resu pub(crate) fn validate_exec_policy(request: &ExecutionRequest) -> Result<(), MxcError> { reject_filesystem_policy(request)?; reject_ui_policy(request)?; - reject_retired_network_fields(request)?; reject_post_provision_network_mode(request)?; if request.policy.network_proxy.is_enabled() && exec_proxy_url(request).is_none() { return Err(MxcError::policy_validation(ERR_PROXY_URL_FORM)); @@ -196,7 +187,7 @@ pub(crate) fn reject_proxy_credentials_in_argv(request: &ExecutionRequest) -> Re } /// The routable proxy URL to inject at exec, or `None` when the proxy is -/// disabled or specified in a non-`url` form (localhost / builtinTestServer). +/// disabled or specified without an original URL. /// Borrows from the request so presence validation does not allocate. pub(crate) fn exec_proxy_url(request: &ExecutionRequest) -> Option<&str> { if !request.policy.network_proxy.is_enabled() { @@ -229,30 +220,6 @@ pub(crate) fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), MxcErro Ok(()) } -/// Reject retired runtime fields before backend provisioning or execution. -pub(crate) fn reject_retired_network_fields(request: &ExecutionRequest) -> Result<(), MxcError> { - let policy = &request.policy; - let field = if policy.default_network_policy != NetworkPolicy::Block { - Some("network.defaultPolicy") - } else if policy.network_enforcement_mode != NetworkEnforcementMode::Capabilities { - Some("network.enforcementMode") - } else if policy.allow_local_network { - Some("network.allowLocalNetwork") - } else if !policy.allowed_hosts.is_empty() { - Some("network.allowedHosts") - } else if !policy.blocked_hosts.is_empty() { - Some("network.blockedHosts") - } else { - None - }; - if let Some(field) = field { - return Err(MxcError::policy_validation(format!( - "WSLc: {field} is retired; use network.egress and network.ingress" - ))); - } - Ok(()) -} - /// Reject any network *mode* field supplied after provision: the posture is /// bound to the provision phase. Presence, not value — an explicit /// `egress.default: "deny"` must still be rejected. @@ -290,14 +257,12 @@ mod tests { "127.0.0.1".to_string(), 8888, )), - builtin_test_server: false, } } fn non_url_proxy() -> ProxyConfig { ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8888)), - builtin_test_server: false, } } @@ -308,7 +273,6 @@ mod tests { "proxy.example".to_string(), 8080, )), - builtin_test_server: false, } } @@ -488,15 +452,6 @@ mod tests { assert_policy_validation(validate_provision_policy(&req).unwrap_err(), "deniedPaths"); } - #[test] - fn provision_rejects_host_filtering() { - let req = request_with_policy(ContainerPolicy { - allowed_hosts: vec!["example.com".to_string()], - ..Default::default() - }); - assert_policy_validation(validate_provision_policy(&req).unwrap_err(), "allowedHosts"); - } - #[test] fn provision_rejects_proxy() { let req = request_with_policy(ContainerPolicy { @@ -527,8 +482,8 @@ mod tests { #[test] fn post_provision_rejects_network_mode_by_presence() { - // Explicit `defaultPolicy: "block"` (value equals the default) must still - // be rejected post-provision: presence, not value, is what matters. + // Even an explicit directional deny (equal to the default) must be + // rejected post-provision: presence, not value, is what matters. let req = request_with_policy(ContainerPolicy { network_mode_specified: true, ..Default::default() @@ -652,67 +607,6 @@ mod tests { validate_exec_policy(&req).unwrap(); } - // ---- allowLocalNetwork ---- - - #[test] - fn provision_rejects_allow_local_network() { - let req = request_with_policy(ContainerPolicy { - allow_local_network: true, - ..Default::default() - }); - assert_policy_validation( - validate_provision_policy(&req).unwrap_err(), - "allowLocalNetwork", - ); - } - - /// Post-provision needs no dedicated `allowLocalNetwork` check: supplying - /// the field sets `network_mode_specified`, which immutability already - /// refuses. Pinned so both rejections can't be dropped as "redundant". - #[test] - fn post_provision_rejects_retired_allow_local_network() { - let req = request_with_policy(ContainerPolicy { - allow_local_network: true, - network_mode_specified: true, - ..Default::default() - }); - assert_policy_validation( - validate_post_provision_policy(&req).unwrap_err(), - "allowLocalNetwork", - ); - assert_policy_validation(validate_exec_policy(&req).unwrap_err(), "allowLocalNetwork"); - } - - // ---- enforcementMode ---- - - #[test] - fn provision_rejects_firewall_and_both_enforcement_modes() { - for mode in [ - NetworkEnforcementMode::Firewall, - NetworkEnforcementMode::Both, - ] { - let req = request_with_policy(ContainerPolicy { - network_enforcement_mode: mode.clone(), - ..Default::default() - }); - assert_policy_validation( - validate_provision_policy(&req).expect_err(&format!("{mode:?} must be rejected")), - "enforcementMode", - ); - } - } - - /// Guards against over-rejection: `capabilities` is honoured, so unlike - /// `ui` it must not be refused for merely being present. - #[test] - fn provision_accepts_explicit_capabilities_enforcement_mode() { - let req = request_with_policy(ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Capabilities, - ..Default::default() - }); - validate_provision_policy(&req).unwrap(); - } - // ---- rejection ordering ---- // // filesystem -> ui -> network. Pinned so reordering the validator bodies is @@ -736,9 +630,11 @@ mod tests { fn ui_error_takes_precedence_over_network() { let req = request_with_policy(ContainerPolicy { ui_specified: true, - allowed_hosts: vec!["example.com".to_string()], - allow_local_network: true, - network_enforcement_mode: NetworkEnforcementMode::Firewall, + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + allow: vec![Default::default()], + ..Default::default() + }), network_proxy: url_proxy(), ..Default::default() }); @@ -760,11 +656,11 @@ mod tests { ..Default::default() }, ContainerPolicy { - allow_local_network: true, - ..Default::default() - }, - ContainerPolicy { - network_enforcement_mode: NetworkEnforcementMode::Firewall, + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + allow: vec![Default::default()], + ..Default::default() + }), ..Default::default() }, ]; diff --git a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs index 3a50b42f2..0060b88e4 100644 --- a/src/mxc-sdk/src/backends/wslc/common/state_aware.rs +++ b/src/mxc-sdk/src/backends/wslc/common/state_aware.rs @@ -524,7 +524,6 @@ fn build_provision_config( request: &ExecutionRequest, config: Option, ) -> Result { - crate::wslc_common::policy::reject_retired_network_fields(request)?; let image = config .as_ref() .and_then(|c| c.image.clone()) @@ -601,9 +600,8 @@ fn build_daemon_volumes(request: &ExecutionRequest) -> Result, .collect()) } -/// Map the request's default network policy to the daemon's binary network -/// mode. Per-host filtering is rejected in validation, so only the default -/// policy participates: `Block` → isolated, `Allow` → bridged NAT. +/// Map the validated directional egress default to the daemon's binary +/// network mode: `Deny` → isolated, `Allow` → bridged NAT. fn map_network(request: &ExecutionRequest) -> NetworkMode { if crate::wslc_common::policy::network_is_isolated(request) { NetworkMode::None @@ -764,63 +762,50 @@ mod tests { } } - /// Refused at provision, the only phase where the network posture is - /// settable — so neither can be silently dropped into the daemon's - /// `ProvisionConfig`, which carries only the binary [`NetworkMode`]. + /// A rule-based posture cannot be mapped to the daemon's binary + /// [`NetworkMode`], so validation must refuse it before provision. #[test] fn validate_provision_rejects_unimplementable_network_posture() { let runner = WslcStateAwareRunner::new(); - for (policy, needle) in [ - ( - ContainerPolicy { - allow_local_network: true, - ..Default::default() - }, - "allowLocalNetwork", - ), - ( - ContainerPolicy { - network_enforcement_mode: - crate::mxc_common::models::NetworkEnforcementMode::Firewall, + let request = ExecutionRequest { + policy: ContainerPolicy { + network_egress: Some(NetworkEgressPolicy { + allow: vec![Default::default()], ..Default::default() - }, - "enforcementMode", - ), - ] { - let request = ExecutionRequest { - policy, + }), ..Default::default() - }; - let err = runner - .validate_provision(&request, None) - .expect_err(&format!("provision must reject {needle}")); - assert_eq!( - err.code, - crate::mxc_common::mxc_error::MxcErrorCode::PolicyValidation - ); - assert!(err.message.contains(needle), "got: {}", err.message); - } + }, + ..Default::default() + }; + let err = runner.validate_provision(&request, None).unwrap_err(); + assert_eq!( + err.code, + crate::mxc_common::mxc_error::MxcErrorCode::PolicyValidation + ); + assert!( + err.message.contains("network.egress"), + "got: {}", + err.message + ); } - /// Guards against over-rejection. Each value is the near-miss of a rejected - /// one, so a gate that flipped between value- and presence-based would fail - /// here only. + /// Guards against rejecting either supported all-or-nothing posture. #[test] fn validate_provision_accepts_the_postures_wslc_can_honour() { let runner = WslcStateAwareRunner::new(); for (label, policy) in [ + ("isolated", ContainerPolicy::default()), ( - "explicit capabilities enforcement mode", + "bridged", ContainerPolicy { - network_enforcement_mode: - crate::mxc_common::models::NetworkEnforcementMode::Capabilities, - ..Default::default() - }, - ), - ( - "explicit allowLocalNetwork=false", - ContainerPolicy { - allow_local_network: false, + network_egress: Some(NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() + }), + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Allow, + }), ..Default::default() }, ), @@ -1196,7 +1181,6 @@ mod tests { "127.0.0.1".to_string(), 8888, )), - builtin_test_server: false, }, ..Default::default() }, diff --git a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs index faa567d4d..19efad8a6 100644 --- a/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs +++ b/src/mxc-sdk/src/backends/wslc/common/wsl_container_runner.rs @@ -24,8 +24,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use crate::mxc_common::logger::{Logger, Mode}; -#[cfg(test)] -use crate::mxc_common::models::NetworkPolicy; use crate::mxc_common::models::{ExecutionRequest, ScriptResponse, WslcConfig}; use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::sandbox_process::StdioMode; @@ -432,7 +430,6 @@ impl ScriptRunner for WSLContainerRunner { policy_mapping::container_working_directory(&request.working_directory) .map_err(|msg| WslcError::Rejected(msg).into_response())?; policy::reject_ui_policy(request).map_err(as_wslc_rejection)?; - policy::reject_retired_network_fields(request).map_err(as_wslc_rejection)?; // The shared validator returns an untagged response; retag it so its // rejections reach SDK callers as `policy_validation` like the checks above. validate_network_policy_support(request, policy::network_policy_support()) @@ -799,7 +796,6 @@ impl WSLContainerRunner { logger: &mut Logger, output: OutputMode, ) -> Result { - policy::reject_retired_network_fields(request).map_err(as_wslc_rejection)?; let _ = writeln!(logger, "{START_CONTAINER_BANNER}"); // WSLc provision-time filesystem-policy gate (D6 normalization → D3 @@ -890,15 +886,14 @@ impl WSLContainerRunner { return Err(sdk_error("WslcSetProcessSettingsCallbacks failed", hr, "")); } - // Route egress through the cooperative proxy: WSLc cannot apply an - // iptables drop-floor (no CAP_NET_ADMIN, no VM-level enforcement hook), - // so per-host policy is enforced at the proxy layer by injecting - // HTTP(S)_PROXY (and scrubbing caller-supplied proxy vars). + // Route cooperative HTTP clients through the caller-managed proxy: + // WSLc has no IP-level enforcement hook, so any destination filtering + // belongs to the proxy. Inject HTTP(S)_PROXY after scrubbing caller + // proxy variables. // See crate::mxc_common::proxy_env. let effective_env: Vec = if request.policy.network_proxy.is_enabled() { - // url-only (also enforced at parse time). Fail fast rather than - // inject an empty HTTP_PROXY= for the localhost/builtinTestServer - // forms, which carry no routable URL. + // A directly constructed request must retain its proxy URL so the + // guest receives a routable endpoint instead of an empty setting. let proxy_url = match request .policy .network_proxy @@ -909,9 +904,9 @@ impl WSLContainerRunner { Some(url) => url, None => { return Err(WslcError::Rejected( - "WSLC: network.proxy requires the 'url' form (a routable proxy URL); \ - the localhost and builtinTestServer forms are not supported because a \ - WSL container runs in its own network namespace." + "WSLC: runtimeConfig.networkProxy requires a URL-backed proxy address. \ + For directly constructed requests, use ProxyAddress::from_url so \ + the guest can receive HTTP(S)_PROXY." .to_string(), ) .into_response()); @@ -1726,60 +1721,14 @@ mod tests { } #[test] - fn validate_runner_rejects_retired_allowlist() { - // block default + allowlist = per-host filtering WSLc can't enforce. - let request = ExecutionRequest { - containment: crate::mxc_common::models::ContainmentBackend::Wslc, - policy: crate::mxc_common::models::ContainerPolicy { - default_network_policy: NetworkPolicy::Block, - allowed_hosts: vec!["example.com".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let runner = WSLContainerRunner::new(&WslcConfig::default()); - let err = runner.validate_runner(&request).unwrap_err(); - assert!(err.error_message.contains("allowedHosts")); - } - - #[test] - fn validate_runner_rejects_blocklist_host_filtering() { - // allow default + blocklist is the other filtering shape. - let request = ExecutionRequest { - containment: crate::mxc_common::models::ContainmentBackend::Wslc, - policy: crate::mxc_common::models::ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - blocked_hosts: vec!["evil.com".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let runner = WSLContainerRunner::new(&WslcConfig::default()); - assert!(runner.validate_runner(&request).is_err()); - } - - #[test] - fn validate_runner_rejects_allow_local_network() { - let request = ExecutionRequest { - containment: crate::mxc_common::models::ContainmentBackend::Wslc, - policy: crate::mxc_common::models::ContainerPolicy { - allow_local_network: true, - ..Default::default() - }, - ..Default::default() - }; - let runner = WSLContainerRunner::new(&WslcConfig::default()); - let err = runner.validate_runner(&request).unwrap_err(); - assert!(err.error_message.contains("allowLocalNetwork")); - } - - /// Neither surface accepts a retired network field. - #[test] - fn both_surfaces_reject_retired_allow_local_network() { + fn both_surfaces_reject_directional_host_filtering() { let request = ExecutionRequest { containment: crate::mxc_common::models::ContainmentBackend::Wslc, policy: crate::mxc_common::models::ContainerPolicy { - allow_local_network: true, + network_egress: Some(crate::mxc_common::models::NetworkEgressPolicy { + allow: vec![Default::default()], + ..Default::default() + }), ..Default::default() }, ..Default::default() @@ -1792,7 +1741,7 @@ mod tests { one_shot.failure_phase, crate::mxc_common::models::FailurePhase::Rejected ); - assert!(one_shot.error_message.contains("allowLocalNetwork")); + assert!(one_shot.error_message.contains("network.egress")); let state_aware = crate::wslc_common::policy::validate_provision_policy(&request).unwrap_err(); @@ -1801,7 +1750,7 @@ mod tests { crate::mxc_common::mxc_error::MxcErrorCode::PolicyValidation ); assert!( - state_aware.message.contains("allowLocalNetwork"), + state_aware.message.contains("allow/deny rules"), "got: {}", state_aware.message ); @@ -1944,44 +1893,6 @@ mod tests { } } - #[test] - fn validate_runner_rejects_unimplementable_enforcement_modes() { - let runner = WSLContainerRunner::new(&WslcConfig::default()); - - for mode in [ - crate::mxc_common::models::NetworkEnforcementMode::Firewall, - crate::mxc_common::models::NetworkEnforcementMode::Both, - ] { - let request = ExecutionRequest { - containment: crate::mxc_common::models::ContainmentBackend::Wslc, - policy: crate::mxc_common::models::ContainerPolicy { - network_enforcement_mode: mode.clone(), - ..Default::default() - }, - ..Default::default() - }; - let err = runner - .validate_runner(&request) - .expect_err(&format!("{mode:?} must be rejected")); - assert!( - err.error_message.contains("enforcementMode"), - "got: {}", - err.error_message - ); - } - - let request = ExecutionRequest { - containment: crate::mxc_common::models::ContainmentBackend::Wslc, - policy: crate::mxc_common::models::ContainerPolicy { - network_enforcement_mode: - crate::mxc_common::models::NetworkEnforcementMode::Capabilities, - ..Default::default() - }, - ..Default::default() - }; - assert!(runner.validate_runner(&request).is_ok()); - } - /// A rejection must abort the request rather than tear a container down /// after building one. `SandboxBackend::spawn`'s half is in `sandbox.rs`. #[test] diff --git a/src/mxc-sdk/src/core/mxc_common/audit.rs b/src/mxc-sdk/src/core/mxc_common/audit.rs index 24f39e4bd..d6fec85b9 100644 --- a/src/mxc-sdk/src/core/mxc_common/audit.rs +++ b/src/mxc-sdk/src/core/mxc_common/audit.rs @@ -952,19 +952,6 @@ mod requirement_conformance { ); } - /// Network policy (M-ETW-4) names the accepted `enforcementMode` values explicitly, and - /// `capabilities` is the OS-enforced (BaseContainer) case. - #[test] - fn network_policy_enforcement_mode_vocabulary_matches_the_requirement() { - use crate::mxc_common::models::NetworkEnforcementMode; - assert_eq!( - NetworkEnforcementMode::Capabilities.as_str(), - "capabilities" - ); - assert_eq!(NetworkEnforcementMode::Firewall.as_str(), "firewall"); - assert_eq!(NetworkEnforcementMode::Both.as_str(), "both"); - } - /// Sandbox teardown (M-ETW-5): "identity, status (success/failure), and what was released". /// Acceptance: "a cleanup failure is distinguishable from success". #[test] diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index fce3da10c..a1e940b9a 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1641,7 +1641,6 @@ fn normalize_common_request_ir( windows_sandbox, hyperlight, experimental_enabled: false, - testing_features_enabled: false, dry_run: false, }) } @@ -1720,7 +1719,7 @@ mod tests { use super::*; use crate::mxc_common::encoding::base64_encode; use crate::mxc_common::logger::Mode; - use crate::mxc_common::models::{NetworkAction, NetworkPolicy, ProxyAddress}; + use crate::mxc_common::models::{NetworkAction, ProxyAddress}; use crate::mxc_common::mxc_error::MxcErrorCode; use std::path::{Path, PathBuf}; @@ -1775,7 +1774,6 @@ mod tests { address: Option, port: Option, original_url: Option, - builtin_test_server: bool, } #[derive(Debug, Clone, PartialEq)] @@ -1798,7 +1796,6 @@ mod tests { address: proxy.map(|address| address.address.clone()), port: proxy.map(|address| address.port), original_url: proxy.and_then(|address| address.original_url.clone()), - builtin_test_server: request.policy.network_proxy.builtin_test_server, }, // These are the complete set of ExecutionRequest model fields hidden by // `#[serde(skip)]`; compare them explicitly so serialization cannot mask @@ -2923,8 +2920,8 @@ mod tests { }; assert!(parsed.request().policy.network_proxy.is_enabled()); assert!(!parsed.request().policy.network_mode_specified); - assert!(parsed.request().policy.allowed_hosts.is_empty()); - assert!(parsed.request().policy.blocked_hosts.is_empty()); + assert!(!parsed.request().policy.network_specified); + assert!(parsed.request().policy.runtime_network_proxy_specified); } fn load_mxc(json: &str) -> Result { @@ -3961,8 +3958,6 @@ mod tests { request.policy.network_ingress.expect("0.9 ingress").default, NetworkAction::Allow ); - assert!(!request.policy.allow_local_network); - assert_eq!(request.policy.default_network_policy, NetworkPolicy::Block); assert!(request.policy.network_mode_specified); } diff --git a/src/mxc-sdk/src/core/mxc_common/diagnostic.rs b/src/mxc-sdk/src/core/mxc_common/diagnostic.rs index 768b8e0f4..2cf6f5468 100644 --- a/src/mxc-sdk/src/core/mxc_common/diagnostic.rs +++ b/src/mxc-sdk/src/core/mxc_common/diagnostic.rs @@ -194,20 +194,12 @@ pub fn redacted_request_json(request: &ExecutionRequest) -> String { .unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize request: {e}\"}}")); // Append network_proxy info (skipped by serde). - let proxy_info = if request.policy.network_proxy.is_enabled() { - let addr = request - .policy - .network_proxy - .address - .as_ref() - .map(|a| crate::mxc_common::proxy_env::redact_proxy_url(&a.to_url())) - .unwrap_or_else(|| "".to_string()); - format!( - "\n[network_proxy: enabled, builtin_test_server={}, address={}]", - request.policy.network_proxy.builtin_test_server, addr - ) - } else { - "\n[network_proxy: disabled]".to_string() + let proxy_info = match request.policy.network_proxy.address.as_ref() { + Some(address) => format!( + "\n[network_proxy: enabled, address={}]", + crate::mxc_common::proxy_env::redact_proxy_url(&address.to_url()) + ), + None => "\n[network_proxy: disabled]".to_string(), }; format!("{json}{proxy_info}") @@ -255,7 +247,10 @@ fn redact_secret_fields_at_path(value: &mut serde_json::Value, path: &mut Vec".to_string()); } else if key_lower == "env" { redact_environment_values(entry); - } else if key_lower == "url" && path.iter().any(|parent| parent == "proxy") { + } else if (key_lower == "networkproxy" + && path.last().is_some_and(|parent| parent == "runtimeconfig")) + || (key_lower == "url" && path.iter().any(|parent| parent == "proxy")) + { if let serde_json::Value::String(url) = entry { *url = crate::mxc_common::proxy_env::redact_proxy_url(url); } @@ -431,7 +426,6 @@ mod tests { let mut request = ExecutionRequest::default(); request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; let json = redacted_request_json(&request); assert!(json.contains("network_proxy: enabled")); @@ -445,6 +439,23 @@ mod tests { assert!(json.contains("network_proxy: disabled")); } + #[test] + fn runtime_proxy_credentials_are_redacted_in_diagnostics() { + let url = format!("http://user:{}@localhost:8080", "pass"); + let mut request = ExecutionRequest::default(); + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::from_url(&url, "localhost".to_string(), 8080)), + }; + let redacted = redacted_request_json(&request); + assert!(!redacted.contains(&url)); + assert!(redacted.contains("http://***@localhost:8080")); + + let raw = serde_json::json!({"runtimeConfig": {"networkProxy": url}}).to_string(); + let redacted = redact_raw_config_json(&raw); + assert!(!redacted.contains(&url)); + assert!(redacted.contains("http://***@localhost:8080")); + } + #[test] fn redact_raw_config_json_hides_isolation_session_user_bundle() { let raw = r#"{ diff --git a/src/mxc-sdk/src/core/mxc_common/error.rs b/src/mxc-sdk/src/core/mxc_common/error.rs index eb9a6da9d..7e4cf7473 100644 --- a/src/mxc-sdk/src/core/mxc_common/error.rs +++ b/src/mxc-sdk/src/core/mxc_common/error.rs @@ -62,9 +62,3 @@ pub const DENIED_PATHS_FEATURE_DISABLED_MSG: &str = QueryProcessSecurityEnvironmentSupport. Run on a build with \ BaseContainer deny support, or use the ProcessContainer dispatcher so it can select \ the AppContainer + DACL fallback (which enforces deniedPaths via DENY ACEs)."; - -#[cfg(target_os = "windows")] -pub const HOST_LISTS_NOT_SUPPORTED_MSG: &str = - "network.allowedHosts / network.blockedHosts are not yet supported on Windows. \ - Remove the host list(s) and rely on network.defaultPolicy (allow / block) or a \ - proxy instead."; diff --git a/src/mxc-sdk/src/core/mxc_common/mod.rs b/src/mxc-sdk/src/core/mxc_common/mod.rs index 8b04d1ac1..abe02c2c2 100644 --- a/src/mxc-sdk/src/core/mxc_common/mod.rs +++ b/src/mxc-sdk/src/core/mxc_common/mod.rs @@ -72,11 +72,6 @@ pub mod system_dir; #[cfg(unix)] pub mod interruptible_reader; -// Unix cooperative network proxy coordinator, used by the Bubblewrap (Linux) -// and Seatbelt (macOS) backends. -#[cfg(unix)] -pub mod unix_proxy_coordinator; - /// Crate-wide lock and guards for tests that mutate process environment. #[cfg(all(test, target_os = "windows"))] pub(crate) mod test_env; diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 67f066e2f..efc592dbd 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -301,44 +301,6 @@ pub struct LxcConfig { pub release: String, } -#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum NetworkPolicy { - Allow, - #[default] - Block, -} - -impl NetworkPolicy { - pub fn as_str(&self) -> &'static str { - match self { - Self::Allow => "allow", - Self::Block => "block", - } - } -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum NetworkEnforcementMode { - #[default] - Capabilities, - Firewall, - Both, -} - -impl NetworkEnforcementMode { - /// Canonical wire string, matching the JSON schema enum. Bounded - /// vocabulary for structured logs. - pub fn as_str(&self) -> &'static str { - match self { - Self::Capabilities => "capabilities", - Self::Firewall => "firewall", - Self::Both => "both", - } - } -} - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum NetworkAction { @@ -598,16 +560,15 @@ pub fn unbracket_host(host: &str) -> &str { .unwrap_or(host) } -/// Proxy configuration parsed from the `network.proxy` JSON field. +/// Normalized runtime proxy configuration. #[derive(Debug, Default, Clone)] pub struct ProxyConfig { pub address: Option, - pub builtin_test_server: bool, } impl ProxyConfig { pub fn is_enabled(&self) -> bool { - self.address.is_some() || self.builtin_test_server + self.address.is_some() } } @@ -718,14 +679,6 @@ pub struct ContainerPolicy { pub enumerate_paths: Vec, pub denied_paths: Vec, pub fallback: FallbackPolicy, - pub default_network_policy: NetworkPolicy, - pub network_enforcement_mode: NetworkEnforcementMode, - /// When true, the sandboxed process may bind() + listen() on local IPs - /// and accept incoming connections. Independent of `default_network_policy` - /// (which governs outbound traffic). - pub allow_local_network: bool, - pub allowed_hosts: Vec, - pub blocked_hosts: Vec, /// Outbound CIDR, protocol, and port policy. pub network_egress: Option, /// Inbound and host-loopback policy. @@ -736,24 +689,21 @@ pub struct ContainerPolicy { pub network_proxy: ProxyConfig, /// Whether the caller supplied a `network` block on the wire (any field /// present), captured at parse time. Distinguishes an absent network policy - /// from an explicit one whose values equal the defaults — the other fields - /// here cannot, since `default_network_policy` defaults to `Block` either - /// way. Used by backends (e.g. IsolationSession) that must reject a network + /// from an explicit one whose values equal the defaults. Used by backends + /// (e.g. IsolationSession) that must reject a network /// policy supplied on a phase where the posture is immutable. Parse-derived, /// never on the wire. #[serde(skip)] pub network_specified: bool, - /// Whether the caller supplied network posture fields: the legacy mode - /// fields (`defaultPolicy`, `enforcementMode`, `allowLocalNetwork`, - /// `allowedHosts`, `blockedHosts`) or directional `egress`/`ingress`. + /// Whether the caller supplied directional network posture fields: + /// `egress` or `ingress`. /// Runtime proxy data is excluded. This lets state-aware backends reject a /// post-provision posture change by presence while still accepting a /// proxy-only exec request. Parse-derived, never on the wire. #[serde(skip)] pub network_mode_specified: bool, - /// Whether `runtimeConfig.networkProxy` was supplied. Distinguishes the - /// schema 0.8 runtime field from the legacy `network.proxy` shape after - /// both have been normalized into `network_proxy`. + /// Whether `runtimeConfig.networkProxy` was supplied, even if the network + /// posture itself was omitted. #[serde(skip)] pub runtime_network_proxy_specified: bool, /// Cross-platform UI policy. @@ -781,33 +731,6 @@ pub struct ContainerPolicy { pub capture_denials: Option, } -/// Do the host lists refine the default egress policy (i.e. require per-host -/// filtering)? Only the list that can tighten the default matters: -/// `Block` → allowlist; `Allow` → blocklist. Shared by the config parser and -/// the WSLc backend so both agree on what "host filtering" means. -pub fn needs_host_filtering( - is_default_block: bool, - allowed_hosts: &[String], - blocked_hosts: &[String], -) -> bool { - if is_default_block { - !allowed_hosts.is_empty() - } else { - !blocked_hosts.is_empty() - } -} - -impl ContainerPolicy { - /// True when this policy's host lists require per-host egress filtering. - pub fn needs_host_filtering(&self) -> bool { - needs_host_filtering( - self.default_network_policy == NetworkPolicy::Block, - &self.allowed_hosts, - &self.blocked_hosts, - ) - } -} - /// Windows denial-capture settings (from `processContainer.captureDenials`). /// The presence of this struct on [`ContainerPolicy::capture_denials`] enables /// capture; the runner records the sandboxed process's ungranted access @@ -1023,7 +946,6 @@ pub struct ExecutionRequest { /// Direct typed SDK construction has no external contract attribution. #[serde(serialize_with = "serialize_source_contract")] pub source_contract: Option, - /// Whether backends supply the default `process.env` block. /// Externally assigned container identifier. pub container_id: String, /// Environment variables as "KEY=VALUE" strings (from `process.env`). @@ -1083,12 +1005,6 @@ pub struct ExecutionRequest { pub hyperlight: Option, /// Whether the --experimental flag was passed. pub experimental_enabled: bool, - /// Whether the --allow-testing-features flag was passed. Gates testing-only, - /// deliberately-permissive helpers (currently `network.proxy.builtinTestServer`) - /// that must never activate from a stock production config. This is a distinct - /// axis from `experimental_enabled`: "experimental" means unstable/new, whereas - /// this means "not-for-production testing scaffolding". - pub testing_features_enabled: bool, /// Dry-run mode: validate config and runner setup then return success /// without executing the sandboxed process. pub dry_run: bool, diff --git a/src/mxc-sdk/src/core/mxc_common/network_parser.rs b/src/mxc-sdk/src/core/mxc_common/network_parser.rs index 117dacb84..12d1d9288 100644 --- a/src/mxc-sdk/src/core/mxc_common/network_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/network_parser.rs @@ -38,76 +38,32 @@ pub fn host_is_canonical_loopback(host: &str) -> bool { ) } -fn convert_wire_proxy_at(proxy: wire::Proxy, path: &str) -> Result { - let wire::Proxy { - builtin_test_server, - localhost, - url, - } = proxy; - let mut proxy_addr = ProxyAddress::new("127.0.0.1".to_string(), 0); - - if let Some(builtin) = builtin_test_server { - if !builtin { - return Err(WxcError::ConfigParse(format!( - "{path}.builtinTestServer must be true when present" - ))); - } - if localhost.is_some() || url.is_some() { - return Err(WxcError::ConfigParse(format!( - "When {path}.builtinTestServer is true, no other proxy options may be set" - ))); - } - return Ok(ProxyConfig { - address: Some(proxy_addr), - builtin_test_server: true, - }); - } - - if let Some(port) = localhost { - if port == 0 { - return Err(WxcError::ConfigParse(format!( - "{path}.localhost must be a port between 1 and 65535" - ))); - } - proxy_addr.port = port; - return Ok(ProxyConfig { - address: Some(proxy_addr), - builtin_test_server: false, - }); +fn convert_wire_proxy_at(url_str: &str, path: &str) -> Result { + let redacted = crate::mxc_common::proxy_env::redact_proxy_url(url_str); + let parsed = url::Url::parse(url_str) + .map_err(|e| WxcError::ConfigParse(format!("{path} is invalid: {e}")))?; + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return Err(WxcError::ConfigParse(format!( + "{path} must use the 'http' or 'https' scheme (got '{scheme}'): {redacted}" + ))); } - - if let Some(url_str) = url { - let redacted = crate::mxc_common::proxy_env::redact_proxy_url(&url_str); - let parsed = url::Url::parse(&url_str) - .map_err(|e| WxcError::ConfigParse(format!("{path} is invalid: {e}")))?; - let scheme = parsed.scheme(); - if scheme != "http" && scheme != "https" { - return Err(WxcError::ConfigParse(format!( - "{path} must use the 'http' or 'https' scheme (got '{scheme}'): {redacted}" - ))); - } - let host = parsed - .host_str() - .ok_or_else(|| { - WxcError::ConfigParse(format!( - "{path} must include a host (e.g., http://localhost:8080), got: {redacted}" - )) - })? - .to_string(); - let port = parsed.port().ok_or_else(|| { + let host = parsed + .host_str() + .ok_or_else(|| { WxcError::ConfigParse(format!( - "{path} must include a port (e.g., http://localhost:8080), got: {redacted}" + "{path} must include a host (e.g., http://localhost:8080), got: {redacted}" )) - })?; - return Ok(ProxyConfig { - address: Some(ProxyAddress::from_url(&url_str, host, port)), - builtin_test_server: false, - }); - } - - Err(WxcError::ConfigParse(format!( - "{path} must specify builtinTestServer, localhost, or url" - ))) + })? + .to_string(); + let port = parsed.port().ok_or_else(|| { + WxcError::ConfigParse(format!( + "{path} must include a port (e.g., http://localhost:8080), got: {redacted}" + )) + })?; + Ok(ProxyConfig { + address: Some(ProxyAddress::from_url(url_str, host, port)), + }) } fn convert_egress(egress: Option) -> Result { @@ -177,16 +133,7 @@ pub(crate) fn parse_network_policy( if let Some(url) = runtime.and_then(|runtime| runtime.network_proxy) { policy.runtime_network_proxy_specified = true; - // Runtime proxy is a 0.8 wire field normalized into the existing - // backend-facing proxy configuration. - let proxy = convert_wire_proxy_at( - wire::Proxy { - localhost: None, - builtin_test_server: None, - url: Some(url), - }, - "runtimeConfig.networkProxy", - )?; + let proxy = convert_wire_proxy_at(&url, "runtimeConfig.networkProxy")?; let host = proxy .address .as_ref() diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index 819b77a37..c2a04b53f 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -40,7 +40,7 @@ //! | `telemetry`, internal `test_feature` | No enforcement effect. | //! | `network_proxy.original_url` | A proxy URL can embed `user:password@`. The host and port *are* hashed. | //! | `capture_denials.output_path` | Only decides where the diagnostic JSON deliverable is written; not enforcement. `capture_denials.mode` remains hashed. | -//! | `dry_run`, `testing_features_enabled` | Invocation modes, not policy. | +//! | `dry_run` | Invocation mode, not policy. | //! | `experimental_enabled` | Authorizes selecting an experimental backend; it does not change the selected backend's enforcement. | //! | `source_contract` | External JSON provenance used only for diagnostics and telemetry. | //! @@ -219,7 +219,6 @@ fn policy_projection(request: &ExecutionRequest) -> Value { inherit_default_env: _excluded_environment_mode, // Invocation modes, not policy. dry_run: _excluded_dry_run, - testing_features_enabled: _excluded_testing_features, } = request; let mut root = Map::new(); @@ -297,10 +296,6 @@ fn proxy_projection(request: &ExecutionRequest) -> Value { let proxy = &request.policy.network_proxy; let mut out = Map::new(); out.insert("enabled".into(), Value::Bool(proxy.is_enabled())); - out.insert( - "builtinTestServer".into(), - Value::Bool(proxy.builtin_test_server), - ); match &proxy.address { Some(addr) => { out.insert("address".into(), Value::String(addr.address.clone())); @@ -580,7 +575,17 @@ mod tests { fn changing_the_network_policy_changes_the_hash() { let baseline = policy_hash(&request()); let mut changed = request(); - changed.policy.default_network_policy = crate::mxc_common::models::NetworkPolicy::Allow; + changed.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: crate::mxc_common::models::NetworkAction::Allow, + ..Default::default() + }); + assert_ne!(baseline, policy_hash(&changed)); + + changed.policy.network_egress = None; + changed.policy.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy { + default: crate::mxc_common::models::NetworkAction::Allow, + ..Default::default() + }); assert_ne!(baseline, policy_hash(&changed)); } @@ -718,7 +723,6 @@ mod tests { let baseline = policy_hash(&request()); let mut changed = request(); changed.dry_run = true; - changed.testing_features_enabled = true; changed.experimental_enabled = true; assert_eq!(baseline, policy_hash(&changed)); } diff --git a/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs b/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs index 538509078..40bd14b9b 100644 --- a/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs +++ b/src/mxc-sdk/src/core/mxc_common/state_aware_binding_tests.rs @@ -106,7 +106,6 @@ fn common_snapshot(request: &ExecutionRequest) -> Value { "proxyAddress": proxy.map(|address| &address.address), "proxyPort": proxy.map(|address| address.port), "proxyUrl": proxy.and_then(|address| address.original_url.as_ref()), - "builtinProxy": request.policy.network_proxy.builtin_test_server, "telemetryKind": request.telemetry.as_ref().and_then(|value| value.requested_sandbox_kind), }) } diff --git a/src/mxc-sdk/src/core/mxc_common/unix_proxy_coordinator.rs b/src/mxc-sdk/src/core/mxc_common/unix_proxy_coordinator.rs deleted file mode 100644 index 8e48c6022..000000000 --- a/src/mxc-sdk/src/core/mxc_common/unix_proxy_coordinator.rs +++ /dev/null @@ -1,661 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Coordinator for the cooperative network proxy used by the Bubblewrap -//! (Linux) and Seatbelt (macOS) backends. -//! -//! # Why this exists -//! -//! Both backends want to route sandboxed traffic through an HTTP proxy -//! **without** root or `CAP_NET_ADMIN`. The Windows AppContainer proxy -//! coordinator achieves enforcement through WinHTTP policy (set by an -//! elevated shim). On Unix the equivalent without privilege is a -//! **cooperative env-var proxy**: -//! -//! 1. The coordinator launches an unprivileged HTTP proxy process (either a -//! user-supplied address, or the bundled `unix-test-proxy` binary). -//! 2. The backend's command builder / runner sets `HTTP_PROXY` / -//! `HTTPS_PROXY` env vars inside the sandbox (Bubblewrap via `--setenv`, -//! Seatbelt via the child's cleared-then-populated environment). -//! 3. Cooperative apps (curl, requests, etc.) honor the env vars and the -//! proxy applies allow/block filtering; non-cooperative apps reaching -//! out via raw sockets bypass enforcement (documented limitation). -//! -//! # Design notes -//! -//! - **Privilege-free**: no iptables, no namespaces, no setuid binaries. -//! - **Bind address is configurable** to allow future LXC reuse (LXC has -//! its own netns, so the proxy needs to bind on the bridge gateway IP). -//! Bubblewrap and Seatbelt share the host netns and pass `"127.0.0.1"`. -//! - **Atomic ready-file**: `unix-test-proxy` writes `.tmp` and -//! renames into place to eliminate partial-read races. -//! - **Parent pipe**: the child watches stdin for EOF so it exits if the -//! executor crashes, on both Linux and macOS. -//! - **`Drop` is silent**: never writes to stderr or `Logger`, because -//! destructors can run at unpredictable times (e.g. during panic -//! propagation) and noisy drops would corrupt the JSON envelope on the -//! executor's stderr. - -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use crate::mxc_common::error::WxcError; -use crate::mxc_common::logger::Logger; -use crate::mxc_common::models::{NetworkPolicy, ProxyAddress, ProxyConfig}; - -/// Maximum time to wait for the test proxy to write its ready file. -const READY_TIMEOUT: Duration = Duration::from_secs(15); - -/// Polling interval while waiting for the ready file to appear. -const READY_POLL_INTERVAL: Duration = Duration::from_millis(50); - -/// Maximum time to wait for the test proxy to exit after SIGTERM. -const STOP_TIMEOUT: Duration = Duration::from_secs(5); - -/// Process counter used (alongside pid + timestamp) to make ready-file -/// names collision-resistant when multiple coordinators run concurrently -/// inside the same process. -static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0); - -/// Generate a unique identifier for ready-file / temp-dir names. -fn generate_unique_id() -> String { - let pid = std::process::id(); - let counter = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0); - format!("{}-{}-{}", pid, counter, nanos) -} - -/// Create a private 0700 temp directory under `/tmp` and return its path. -fn create_private_temp_dir(unique_id: &str) -> Result { - let dir = std::env::temp_dir().join(format!("mxc-proxy-{}", unique_id)); - fs::create_dir(&dir).map_err(|err| { - WxcError::NetworkProxy(format!( - "Failed to create proxy temp dir {}: {}", - dir.display(), - err - )) - })?; - - // Best-effort 0700 chmod so other users cannot snoop the ready file. - let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); - - Ok(dir) -} - -/// Best-effort cleanup of a temp directory created by -/// [`create_private_temp_dir`]. Never panics; ignores errors. -fn remove_temp_dir(dir: &Path) { - let _ = fs::remove_dir_all(dir); -} - -/// Resolve a sibling binary next to the currently running executable. -fn resolve_sibling_binary(name: &str) -> Result { - let exe = std::env::current_exe().map_err(|err| { - WxcError::NetworkProxy(format!("cannot determine current exe path: {}", err)) - })?; - let dir = exe - .parent() - .ok_or_else(|| WxcError::NetworkProxy("current exe has no parent directory".into()))?; - let path = dir.join(name); - if path.exists() { - Ok(path) - } else { - Err(WxcError::NetworkProxy(format!( - "{} not found at {}", - name, - path.display() - ))) - } -} - -/// Bookkeeping for a running `unix-test-proxy` child process. -struct TestProxyChild { - child: Child, - ready_file: PathBuf, - temp_dir: PathBuf, -} - -/// Send SIGTERM to a process group/PID (best-effort). -fn send_sigterm(pid: u32) { - // SAFETY: `kill(2)` with SIGTERM is always defined; we ignore the - // return value because the process may already be gone. - unsafe { - libc::kill(pid as libc::pid_t, libc::SIGTERM); - } -} - -/// Send SIGKILL to a PID (best-effort). -fn send_sigkill(pid: u32) { - unsafe { - libc::kill(pid as libc::pid_t, libc::SIGKILL); - } -} - -/// Wait up to `timeout` for the child to exit, polling `try_wait`. -/// Returns `true` if the child exited cleanly within the window. -fn wait_with_timeout(child: &mut Child, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - loop { - match child.try_wait() { - Ok(Some(_)) => return true, - Ok(None) => { - if Instant::now() >= deadline { - return false; - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(_) => return false, - } - } -} - -/// Coordinator for the network proxy used by Unix backends. -/// -/// Cooperative model: launches an unprivileged HTTP proxy (either external -/// or the bundled `unix-test-proxy`), and the caller is responsible for -/// setting `HTTP_PROXY` / `HTTPS_PROXY` env vars inside the sandbox. -/// -/// The coordinator is **not** active until [`start`](Self::start) succeeds, -/// and is automatically cleaned up by [`stop`](Self::stop) or `Drop`. -#[derive(Default)] -pub struct UnixProxyCoordinator { - proxy_address: Option, - test_proxy: Option, -} - -impl UnixProxyCoordinator { - /// Create an inactive coordinator. Call [`start`](Self::start) to launch. - pub fn new() -> Self { - Self::default() - } - - /// Returns `true` once a proxy has been started. - pub fn is_active(&self) -> bool { - self.proxy_address.is_some() - } - - /// Returns the resolved proxy address (if any). - pub fn address(&self) -> Option<&ProxyAddress> { - self.proxy_address.as_ref() - } - - /// Activate the proxy. - /// - /// - If `proxy_config.builtin_test_server` is `true`, launches the - /// bundled `unix-test-proxy` binary on `bind_address:0` and reads - /// the assigned port from the proxy's ready file. `allowed_hosts`, - /// `blocked_hosts`, and `default_policy` are passed to the test - /// proxy as `--allow-host` / `--block-host` / `--default-policy` - /// flags so the cooperative env-var proxy honors the request's - /// `defaultPolicy: "block"` semantics (deny-by-default). - /// - Otherwise, uses the externally provided `proxy_config.address`. - /// `allowed_hosts` / `blocked_hosts` / `default_policy` are ignored - /// in this case: the external proxy is assumed to apply its own - /// policy. - /// - If the proxy config is disabled (`is_enabled()` returns `false`), - /// this is a no-op and the coordinator remains inactive. - /// - /// `bind_address` is the IP the test proxy listens on. For Bubblewrap - /// pass `"127.0.0.1"`; future LXC reuse can pass a bridge gateway IP. - pub fn start( - &mut self, - proxy_config: &ProxyConfig, - bind_address: &str, - allowed_hosts: &[String], - blocked_hosts: &[String], - default_policy: NetworkPolicy, - logger: &mut Logger, - ) -> Result<(), WxcError> { - if self.is_active() { - return Err(WxcError::NetworkProxy( - "Unix network proxy is already active".into(), - )); - } - - if !proxy_config.is_enabled() { - return Ok(()); - } - - let address = if proxy_config.builtin_test_server { - let port = self.launch_test_proxy( - bind_address, - allowed_hosts, - blocked_hosts, - default_policy, - logger, - )?; - ProxyAddress::new(bind_address.to_string(), port) - } else if let Some(ref addr) = proxy_config.address { - addr.clone() - } else { - // is_enabled() was true but neither variant is set -- defensive - // guard, should be unreachable. - return Err(WxcError::NetworkProxy( - "Network proxy enabled but no address or builtin server configured".into(), - )); - }; - - logger.log_line(&format!("Unix network proxy active: {}", address.to_url(),)); - self.proxy_address = Some(address); - - Ok(()) - } - - /// Spawn `unix-test-proxy` and read its port from the ready file. - /// - /// On any post-spawn error this method kills the child, waits briefly, - /// and removes the temp directory before returning -- callers must - /// **not** rely on `Drop` for cleanup of failed launches. - fn launch_test_proxy( - &mut self, - bind_address: &str, - allow_hosts: &[String], - block_hosts: &[String], - default_policy: NetworkPolicy, - logger: &mut Logger, - ) -> Result { - logger.log_line( - "WARNING: Starting builtin unix-test-proxy -- this is for integration \ - testing only, NOT for production use.", - ); - - let unique_id = generate_unique_id(); - let temp_dir = create_private_temp_dir(&unique_id)?; - let ready_file = temp_dir.join("ready.port"); - - let proxy_exe = match resolve_sibling_binary("unix-test-proxy") { - Ok(path) => path, - Err(err) => { - remove_temp_dir(&temp_dir); - return Err(err); - } - }; - - let default_policy_arg = match default_policy { - NetworkPolicy::Allow => "allow", - NetworkPolicy::Block => "block", - }; - - let mut cmd = Command::new(&proxy_exe); - cmd.arg("--ready-file") - .arg(&ready_file) - .arg("--bind-address") - .arg(bind_address) - .arg("--default-policy") - .arg(default_policy_arg); - for host in allow_hosts { - cmd.arg("--allow-host").arg(host); - } - for host in block_hosts { - cmd.arg("--block-host").arg(host); - } - // Keep a private stdin pipe open for the child's parent-lifetime - // watcher. If the executor exits unexpectedly, EOF tells the proxy to - // shut down. Null stdout/stderr avoid corrupting the executor's JSON - // envelope with proxy diagnostics. - cmd.stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(err) => { - remove_temp_dir(&temp_dir); - return Err(WxcError::NetworkProxy(format!( - "Failed to launch unix-test-proxy: {}", - err - ))); - } - }; - - let port = match poll_for_port(&ready_file, &mut child) { - Ok(p) => p, - Err(err) => { - let pid = child.id(); - send_sigterm(pid); - if !wait_with_timeout(&mut child, STOP_TIMEOUT) { - send_sigkill(pid); - let _ = child.wait(); - } - remove_temp_dir(&temp_dir); - return Err(err); - } - }; - - self.test_proxy = Some(TestProxyChild { - child, - ready_file, - temp_dir, - }); - - logger.log_line(&format!( - "unix-test-proxy listening on {}:{}", - bind_address, port - )); - - Ok(port) - } - - /// Stop the proxy if active. Idempotent and best-effort. Errors during - /// shutdown are logged but never returned. - pub fn stop(&mut self, logger: &mut Logger) { - if let Some(mut tp) = self.test_proxy.take() { - let pid = tp.child.id(); - logger.log_line("Stopping unix-test-proxy..."); - send_sigterm(pid); - if wait_with_timeout(&mut tp.child, STOP_TIMEOUT) { - logger.log_line("unix-test-proxy exited."); - } else { - logger - .log_line("Warning: unix-test-proxy did not exit within 5s; sending SIGKILL."); - send_sigkill(pid); - let _ = tp.child.wait(); - } - let _ = fs::remove_file(&tp.ready_file); - remove_temp_dir(&tp.temp_dir); - } - self.proxy_address = None; - } -} - -impl Drop for UnixProxyCoordinator { - /// Silent best-effort cleanup if the coordinator is still active at - /// drop time. **Never** writes to stderr or `Logger` because the drop - /// may run during panic unwinding and we must not corrupt the JSON - /// envelope on `lxc-exec`'s stderr. - fn drop(&mut self) { - if let Some(mut tp) = self.test_proxy.take() { - let pid = tp.child.id(); - send_sigterm(pid); - if !wait_with_timeout(&mut tp.child, STOP_TIMEOUT) { - send_sigkill(pid); - let _ = tp.child.wait(); - } - let _ = fs::remove_file(&tp.ready_file); - remove_temp_dir(&tp.temp_dir); - } - self.proxy_address = None; - } -} - -/// Wait for the ready file to appear, parse the port, then re-check that -/// the child is still alive. Returns an error if the file does not appear -/// in time, the contents are not a valid port, or the child exited before -/// or after publishing the port. -fn poll_for_port(ready_file: &Path, child: &mut Child) -> Result { - let deadline = Instant::now() + READY_TIMEOUT; - loop { - // 1. Has the child exited prematurely? - match child.try_wait() { - Ok(Some(status)) => { - return Err(WxcError::NetworkProxy(format!( - "unix-test-proxy exited before becoming ready (status: {:?})", - status - ))); - } - Ok(None) => {} - Err(err) => { - return Err(WxcError::NetworkProxy(format!( - "Failed to query unix-test-proxy status: {}", - err - ))); - } - } - - // 2. Has the ready file appeared? - if ready_file.exists() { - break; - } - - if Instant::now() >= deadline { - return Err(WxcError::NetworkProxy(format!( - "Timed out waiting for unix-test-proxy ready file ({:?})", - READY_TIMEOUT - ))); - } - std::thread::sleep(READY_POLL_INTERVAL); - } - - let content = fs::read_to_string(ready_file) - .map_err(|err| WxcError::NetworkProxy(format!("Failed to read ready file: {}", err)))?; - let port: u16 = content.trim().parse().map_err(|err| { - WxcError::NetworkProxy(format!( - "Invalid port in ready file '{}': {}", - content.trim(), - err - )) - })?; - - // 3. Re-check liveness AFTER parsing port. A dead proxy with a valid - // port is useless to the caller and must surface as an error. - match child.try_wait() { - Ok(Some(status)) => Err(WxcError::NetworkProxy(format!( - "unix-test-proxy exited immediately after publishing port (status: {:?})", - status - ))), - Ok(None) => Ok(port), - Err(err) => Err(WxcError::NetworkProxy(format!( - "Failed to re-check unix-test-proxy status: {}", - err - ))), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_logger() -> Logger { - Logger::new(crate::mxc_common::logger::Mode::Buffer) - } - - #[test] - fn new_coordinator_is_inactive() { - let c = UnixProxyCoordinator::new(); - assert!(!c.is_active()); - assert!(c.address().is_none()); - } - - #[test] - fn default_coordinator_is_inactive() { - let c = UnixProxyCoordinator::default(); - assert!(!c.is_active()); - } - - #[test] - fn start_with_disabled_proxy_is_noop() { - let mut c = UnixProxyCoordinator::new(); - let mut logger = make_logger(); - let cfg = ProxyConfig::default(); - assert!(!cfg.is_enabled()); - c.start( - &cfg, - "127.0.0.1", - &[], - &[], - NetworkPolicy::Allow, - &mut logger, - ) - .unwrap(); - assert!(!c.is_active()); - } - - #[test] - fn start_with_external_address_activates() { - let mut c = UnixProxyCoordinator::new(); - let mut logger = make_logger(); - let cfg = ProxyConfig { - address: Some(ProxyAddress::new("127.0.0.1".into(), 8888)), - ..Default::default() - }; - assert!(cfg.is_enabled()); - - c.start( - &cfg, - "127.0.0.1", - &[], - &[], - NetworkPolicy::Allow, - &mut logger, - ) - .unwrap(); - assert!(c.is_active()); - let addr = c.address().unwrap(); - assert_eq!(addr.port(), 8888); - - c.stop(&mut logger); - assert!(!c.is_active()); - } - - #[test] - fn start_is_rejected_if_already_active() { - let mut c = UnixProxyCoordinator::new(); - let mut logger = make_logger(); - let cfg = ProxyConfig { - address: Some(ProxyAddress::new("127.0.0.1".into(), 9001)), - ..Default::default() - }; - - c.start( - &cfg, - "127.0.0.1", - &[], - &[], - NetworkPolicy::Allow, - &mut logger, - ) - .unwrap(); - let err = c - .start( - &cfg, - "127.0.0.1", - &[], - &[], - NetworkPolicy::Allow, - &mut logger, - ) - .unwrap_err(); - match err { - WxcError::NetworkProxy(msg) => assert!(msg.contains("already active")), - other => panic!("unexpected error: {:?}", other), - } - } - - #[test] - fn stop_is_idempotent() { - let mut c = UnixProxyCoordinator::new(); - let mut logger = make_logger(); - c.stop(&mut logger); - c.stop(&mut logger); - assert!(!c.is_active()); - } - - #[test] - fn generate_unique_id_produces_distinct_ids() { - let a = generate_unique_id(); - let b = generate_unique_id(); - assert_ne!(a, b); - } - - #[test] - fn create_and_remove_private_temp_dir() { - let id = generate_unique_id(); - let dir = create_private_temp_dir(&id).unwrap(); - assert!(dir.exists()); - - #[cfg(target_os = "linux")] - { - use std::os::unix::fs::PermissionsExt; - let meta = fs::metadata(&dir).unwrap(); - assert_eq!(meta.permissions().mode() & 0o777, 0o700); - } - - remove_temp_dir(&dir); - assert!(!dir.exists()); - } - - #[test] - fn poll_for_port_times_out_when_file_never_appears() { - let id = generate_unique_id(); - let dir = create_private_temp_dir(&id).unwrap(); - let ready_file = dir.join("ready.port"); - - // Spawn a sleep that lives longer than the test timeout below. - let mut child = Command::new("sleep") - .arg("30") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("sleep is available on Unix test hosts"); - - // Override the timeout: use a short window for the test by - // temporarily inlining the polling logic. - let deadline = Instant::now() + Duration::from_millis(300); - let result: Result = loop { - match child.try_wait() { - Ok(Some(status)) => { - break Err(WxcError::NetworkProxy(format!( - "exited early: {:?}", - status - ))); - } - Ok(None) => {} - Err(err) => break Err(WxcError::NetworkProxy(format!("query: {}", err))), - } - if ready_file.exists() { - // Shouldn't happen in this test. - break Ok(0); - } - if Instant::now() >= deadline { - break Err(WxcError::NetworkProxy("timeout".into())); - } - std::thread::sleep(Duration::from_millis(20)); - }; - - assert!(result.is_err()); - - let _ = child.kill(); - let _ = child.wait(); - remove_temp_dir(&dir); - } - - #[test] - fn poll_for_port_detects_premature_child_exit() { - let id = generate_unique_id(); - let dir = create_private_temp_dir(&id).unwrap(); - let ready_file = dir.join("ready.port"); - - // `true` exits successfully immediately. - let mut child = Command::new("true") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("true is available on Unix test hosts"); - - // Give the child a moment to exit. - std::thread::sleep(Duration::from_millis(100)); - - let err = poll_for_port(&ready_file, &mut child).unwrap_err(); - match err { - WxcError::NetworkProxy(msg) => { - assert!( - msg.contains("exited before becoming ready"), - "unexpected message: {}", - msg - ); - } - other => panic!("unexpected error: {:?}", other), - } - - remove_temp_dir(&dir); - } -} diff --git a/src/mxc-sdk/src/core/mxc_common/validator.rs b/src/mxc-sdk/src/core/mxc_common/validator.rs index 07ff2b219..fa879aaf9 100644 --- a/src/mxc-sdk/src/core/mxc_common/validator.rs +++ b/src/mxc-sdk/src/core/mxc_common/validator.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::mxc_common::models::{ExecutionRequest, NetworkAction, NetworkPolicy, ScriptResponse}; +use crate::mxc_common::models::{ExecutionRequest, NetworkAction, ScriptResponse}; use crate::mxc_common::mxc_error::MxcError; /// Declares which optional network policy features a backend enforces. @@ -55,27 +55,6 @@ impl std::ops::BitOr for NetworkPolicySupport { } } -fn validate_legacy_host_lists(request: &ExecutionRequest) -> Result<(), ScriptResponse> { - let policy = &request.policy; - - if policy.default_network_policy == NetworkPolicy::Block - && policy.allowed_hosts.is_empty() - && !policy.blocked_hosts.is_empty() - { - return Err(ScriptResponse::error( - "blockedHosts requires allowedHosts when network.defaultPolicy='block'", - )); - } - - if policy.default_network_policy == NetworkPolicy::Allow && !policy.allowed_hosts.is_empty() { - return Err(ScriptResponse::error( - "allowedHosts requires network.defaultPolicy='block'", - )); - } - - Ok(()) -} - /// Reject network policy features that the selected backend cannot enforce. pub fn validate_network_policy_support( request: &ExecutionRequest, @@ -97,19 +76,6 @@ pub fn validate_network_policy_support( "network.egress.default is not supported by the selected backend", )); } - if !support.contains(NetworkPolicySupport::EGRESS_DEFAULT) - && request - .policy - .network_egress - .as_ref() - .is_some_and(|egress| egress.default == NetworkAction::Deny) - && request.policy.default_network_policy == NetworkPolicy::Allow - { - return Err(ScriptResponse::rejected( - "network.egress.default='deny' conflicts with the legacy outbound policy", - )); - } - if !support.contains(NetworkPolicySupport::EGRESS_RULES) && request .policy @@ -135,19 +101,6 @@ pub fn validate_network_policy_support( "network.ingress.default is not supported by the selected backend", )); } - if !support.contains(NetworkPolicySupport::INGRESS_DEFAULT) - && request - .policy - .network_ingress - .as_ref() - .is_some_and(|ingress| ingress.default == NetworkAction::Deny) - && request.policy.allow_local_network - { - return Err(ScriptResponse::rejected( - "network.ingress.default='deny' conflicts with the legacy inbound policy", - )); - } - if !support.contains(NetworkPolicySupport::HOST_LOOPBACK) && request .policy @@ -161,21 +114,6 @@ pub fn validate_network_policy_support( "network.ingress.hostLoopback is not supported by the selected backend", )); } - if !support.contains(NetworkPolicySupport::HOST_LOOPBACK) - && request - .policy - .network_ingress - .as_ref() - .is_some_and(|ingress| ingress.host_loopback == NetworkAction::Deny) - && request.policy.allow_local_network - { - return Err(ScriptResponse::rejected( - "network.ingress.hostLoopback='deny' conflicts with the legacy inbound policy", - )); - } - - validate_legacy_host_lists(request)?; - if !support.contains(NetworkPolicySupport::PROXY_PEER_IDENTITY) && request.policy.allowed_proxy_peer.is_some() { @@ -210,19 +148,6 @@ pub fn validate_common(request: &ExecutionRequest) -> Result<(), ScriptResponse> return Err(ScriptResponse::error("Script content must not be empty.")); } - // Enforce the testing-only-features gate centrally so it applies uniformly - // to all backends — every backend runs `validate_common` before executing. - // Currently this gates `network.proxy.builtinTestServer` (a deliberately- - // permissive test proxy); see `ExecutionRequest::testing_features_enabled` - // for the rationale behind the dedicated `--allow-testing-features` axis. - if request.policy.network_proxy.builtin_test_server && !request.testing_features_enabled { - return Err(ScriptResponse::error( - "network.proxy.builtinTestServer is a testing-only feature and requires the \ - --allow-testing-features flag. For production, point network.proxy at a real \ - HTTP proxy via 'localhost' or 'url'.", - )); - } - if !request.policy.enumerate_paths.is_empty() && request.containment != crate::mxc_common::models::ContainmentBackend::ProcessContainer { @@ -251,8 +176,8 @@ pub fn validate_exec_common(request: &ExecutionRequest) -> Result<(), MxcError> mod tests { use super::*; use crate::mxc_common::models::{ - ContainerPolicy, ExecutionRequest, NetworkAction, NetworkEgressPolicy, - NetworkIngressPolicy, NetworkRule, ProxyAddress, ProxyConfig, + ExecutionRequest, NetworkAction, NetworkEgressPolicy, NetworkIngressPolicy, NetworkRule, + ProxyAddress, ProxyConfig, }; use crate::mxc_common::mxc_error::MxcErrorCode; @@ -341,123 +266,11 @@ mod tests { } #[test] - fn rejects_builtin_test_server_without_testing_features() { - let mut req = ExecutionRequest { - script_code: "echo hi".to_string(), - ..Default::default() - }; - req.policy.network_proxy.builtin_test_server = true; - req.testing_features_enabled = false; - - let err = validate_common(&req).unwrap_err(); - assert!( - err.error_message.contains("builtinTestServer") - && err.error_message.contains("--allow-testing-features"), - "expected testing-gate error, got: {}", - err.error_message - ); - } - - #[test] - fn accepts_builtin_test_server_with_testing_features() { - let mut req = ExecutionRequest { - script_code: "echo hi".to_string(), - ..Default::default() - }; - req.policy.network_proxy.builtin_test_server = true; - req.testing_features_enabled = true; - - assert!(validate_common(&req).is_ok()); - } - - #[test] - fn network_support_accepts_valid_legacy_host_list_combinations() { - for (default_network_policy, allowed_hosts, blocked_hosts) in [ - (NetworkPolicy::Block, vec![], vec![]), - ( - NetworkPolicy::Block, - vec!["203.0.113.7".to_string()], - vec![], - ), - ( - NetworkPolicy::Block, - vec!["203.0.113.0/24".to_string()], - vec!["203.0.113.7".to_string()], - ), - (NetworkPolicy::Allow, vec![], vec![]), - ( - NetworkPolicy::Allow, - vec![], - vec!["203.0.113.7".to_string()], - ), - ] { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy, - allowed_hosts, - blocked_hosts, - ..Default::default() - }, - ..Default::default() - }; - - for support in [NetworkPolicySupport::default(), NetworkPolicySupport::ALL] { - assert!( - validate_network_policy_support(&request, support).is_ok(), - "valid legacy host-list policy was rejected for support {support:?}" - ); - } - } - } - - #[test] - fn network_support_rejects_legacy_lists_that_do_not_refine_the_default() { - let cases = [ - ( - NetworkPolicy::Block, - vec![], - vec!["203.0.113.7".to_string()], - "blockedHosts requires allowedHosts when network.defaultPolicy='block'", - ), - ( - NetworkPolicy::Allow, - vec!["203.0.113.7".to_string()], - vec![], - "allowedHosts requires network.defaultPolicy='block'", - ), - ( - NetworkPolicy::Allow, - vec!["203.0.113.7".to_string()], - vec!["203.0.113.8".to_string()], - "allowedHosts requires network.defaultPolicy='block'", - ), - ]; - - for (default_network_policy, allowed_hosts, blocked_hosts, expected) in cases { - let request = ExecutionRequest { - policy: ContainerPolicy { - default_network_policy, - allowed_hosts, - blocked_hosts, - ..Default::default() - }, - ..Default::default() - }; - - for support in [NetworkPolicySupport::default(), NetworkPolicySupport::ALL] { - let error = validate_network_policy_support(&request, support).unwrap_err(); - assert_eq!(error.error_message, expected); - } - } - } - - #[test] - fn network_support_reports_directional_error_before_legacy_host_list_error() { + fn network_support_reports_egress_error_before_ingress_error() { let mut request = ExecutionRequest::default(); request.policy.network_mode_specified = true; request.policy.network_egress = Some(NetworkEgressPolicy::default()); - request.policy.default_network_policy = NetworkPolicy::Allow; - request.policy.allowed_hosts = vec!["203.0.113.7".to_string()]; + request.policy.network_ingress = Some(NetworkIngressPolicy::default()); let error = validate_network_policy_support(&request, NetworkPolicySupport::default()).unwrap_err(); @@ -517,7 +330,6 @@ mod tests { request.policy.runtime_network_proxy_specified = true; request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; let error = validate_network_policy_support( &request, @@ -557,7 +369,6 @@ mod tests { }); request.policy.network_proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; request.policy.runtime_network_proxy_specified = true; request.policy.allowed_proxy_peer = Some("Contoso.Proxy_123".to_string()); @@ -581,33 +392,6 @@ mod tests { assert!(error.error_message.contains("network.ingress.default")); } - #[test] - fn network_support_rejects_inconsistent_dual_model_defaults() { - let mut request = ExecutionRequest::default(); - request.policy.network_egress = Some(NetworkEgressPolicy::default()); - request.policy.default_network_policy = NetworkPolicy::Allow; - let error = - validate_network_policy_support(&request, NetworkPolicySupport::default()).unwrap_err(); - assert!(error.error_message.contains("legacy outbound policy")); - - let mut request = ExecutionRequest::default(); - request.policy.network_ingress = Some(NetworkIngressPolicy::default()); - request.policy.allow_local_network = true; - let error = validate_network_policy_support(&request, NetworkPolicySupport::HOST_LOOPBACK) - .unwrap_err(); - assert!(error.error_message.contains("network.ingress.default")); - assert!(error.error_message.contains("legacy inbound policy")); - - let mut request = ExecutionRequest::default(); - request.policy.network_ingress = Some(NetworkIngressPolicy::default()); - request.policy.allow_local_network = true; - let error = - validate_network_policy_support(&request, NetworkPolicySupport::INGRESS_DEFAULT) - .unwrap_err(); - assert!(error.error_message.contains("network.ingress.hostLoopback")); - assert!(error.error_message.contains("legacy inbound policy")); - } - #[test] fn network_support_accepts_implicit_directional_defaults_without_declared_features() { let mut request = ExecutionRequest::default(); diff --git a/src/mxc-sdk/src/core/mxc_common/wire.rs b/src/mxc-sdk/src/core/mxc_common/wire.rs index 1aa80be8d..8a845fa79 100644 --- a/src/mxc-sdk/src/core/mxc_common/wire.rs +++ b/src/mxc-sdk/src/core/mxc_common/wire.rs @@ -368,18 +368,6 @@ pub struct RuntimeConfig { pub network_proxy: Option, } -/// Proxy configuration. Exactly one variant applies. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Proxy { - /// External localhost proxy port. - pub localhost: Option, - /// Have wxc launch its own built-in test proxy. - pub builtin_test_server: Option, - /// Proxy URL (parsed into host:port). - pub url: Option, -} - /// Cross-platform UI isolation policy. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/src/mxc-sdk/tests/mxc_common_proxy_address_spec.rs b/src/mxc-sdk/tests/mxc_common_proxy_address_spec.rs index cc5496b67..1bd94c8c9 100644 --- a/src/mxc-sdk/tests/mxc_common_proxy_address_spec.rs +++ b/src/mxc-sdk/tests/mxc_common_proxy_address_spec.rs @@ -27,7 +27,7 @@ //! field) has live callers today. `appcontainer_runner::inject_proxy_vars` //! turns `to_url()` into the `HTTP_PROXY` / `HTTPS_PROXY` values injected into //! the sandboxed process, `proxy_coordinator` uses it to launch the elevated -//! shim, `unix_proxy_coordinator` logs it, `config_parser` produces addresses +//! shim, Unix backends log redacted URLs, `config_parser` produces addresses //! via `from_url`, and `wsl_container_runner` reads the `original_url` field //! directly. //! * The pin surface (`host_pin`, `hosts_line`, `ProxyHostPin`) still has no diff --git a/src/mxc-sdk/tests/mxc_common_proxy_env_spec.rs b/src/mxc-sdk/tests/mxc_common_proxy_env_spec.rs index 23ea51a22..f4ee07c39 100644 --- a/src/mxc-sdk/tests/mxc_common_proxy_env_spec.rs +++ b/src/mxc-sdk/tests/mxc_common_proxy_env_spec.rs @@ -350,7 +350,6 @@ fn cooperative_env_appends_managed_keys_after_non_proxy_entries() { fn apply_proxy_env_enabled_sets_keys() { let proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; let expected_url = proxy.address.as_ref().unwrap().to_url(); let mut env = vec![ @@ -376,7 +375,6 @@ fn apply_proxy_env_enabled_sets_keys() { fn apply_proxy_env_scrubs_bare_valueless_proxy_key() { let proxy = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), - builtin_test_server: false, }; let mut env = vec!["HTTP_PROXY".to_string(), "PATH=/usr/bin".to_string()]; diff --git a/src/testing/unix_test_proxy/src/main.rs b/src/testing/unix_test_proxy/src/main.rs index e398f6ece..9268cbafb 100644 --- a/src/testing/unix_test_proxy/src/main.rs +++ b/src/testing/unix_test_proxy/src/main.rs @@ -1,18 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Standalone binary for the Unix builtin test proxy. +//! Standalone Unix test proxy for caller-managed integration tests. //! //! **Testing-only tool.** Launches a minimal HTTP CONNECT proxy on an //! OS-assigned port, atomically writes the port to a ready file, then waits //! for SIGTERM, SIGINT, or EOF on its parent-lifetime pipe before shutting //! down. //! -//! Designed to be spawned by `mxc_sdk::mxc_common::unix_proxy_coordinator` to provide -//! cooperative, unprivileged proxy-based enforcement of `allowedHosts` / -//! `blockedHosts`. Used by the Bubblewrap backend on Linux and the Seatbelt -//! backend on macOS. It builds and runs on any Unix; the CONNECT proxy itself -//! (`proxy`) is platform-neutral. +//! Test harnesses launch it independently and supply its address through +//! `runtimeConfig.networkProxy`. MXC does not start it from a request. +//! It builds and runs on any Unix; the CONNECT proxy itself (`proxy`) is +//! platform-neutral. #[cfg(unix)] mod proxy; diff --git a/src/testing/wxc_test_driver/src/main.rs b/src/testing/wxc_test_driver/src/main.rs index 53e4debad..bf07db155 100644 --- a/src/testing/wxc_test_driver/src/main.rs +++ b/src/testing/wxc_test_driver/src/main.rs @@ -65,18 +65,6 @@ fn run_configs(config_path: &std::path::Path, debug: bool) -> anyhow::Result<()> if config.get("containment").and_then(|c| c.as_str()) == Some("windows_sandbox") { cmd.arg("--experimental"); } - - // builtinTestServer is testing-only scaffolding gated behind - // --allow-testing-features — pass it when the config opts in. - let builtin_test_server = config - .get("network") - .and_then(|n| n.get("proxy")) - .and_then(|p| p.get("builtinTestServer")) - .and_then(|b| b.as_bool()) - .unwrap_or(false); - if builtin_test_server { - cmd.arg("--allow-testing-features"); - } } let output = cmd.output()?; diff --git a/src/testing/wxc_test_proxy/README.md b/src/testing/wxc_test_proxy/README.md index d584abcee..f06724416 100644 --- a/src/testing/wxc_test_proxy/README.md +++ b/src/testing/wxc_test_proxy/README.md @@ -8,8 +8,11 @@ Minimal HTTP CONNECT proxy for `wxc` integration testing. Tunnels HTTPS via `CON ## Usage -Launched automatically by `wxc-exec` when the config specifies: +Launch this binary from an integration harness that owns a Windows cleanup +event and passes its PID. The proxy writes the selected loopback port to the +ready file; the harness can then supply its URL through +`runtimeConfig.networkProxy`. MXC does not start this binary from a request. -```json -{ "network": { "proxy": { "builtinTestServer": true } } } +```text +wxc-test-proxy --ready-file --cleanup-event --parent-pid ``` diff --git a/src/testing/wxc_test_proxy/src/main.rs b/src/testing/wxc_test_proxy/src/main.rs index b8ad12ad8..e5ea69e4a 100644 --- a/src/testing/wxc_test_proxy/src/main.rs +++ b/src/testing/wxc_test_proxy/src/main.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Standalone binary for the builtin test proxy. +//! Standalone test proxy binary for external integration harnesses. //! //! **This is a testing-only tool.** It launches a minimal HTTP CONNECT proxy //! on an OS-assigned port, writes the port to a ready file, and waits for a @@ -17,7 +17,7 @@ mod proxy; #[derive(Parser)] #[command( name = "wxc-test-proxy", - about = "Builtin test proxy for wxc integration testing (NOT for production use)" + about = "Standalone test proxy for integration testing (NOT for production use)" )] struct Cli { /// Path where the proxy writes its port number once ready. diff --git a/src/tools/lxc/src/main.rs b/src/tools/lxc/src/main.rs index 0f981f1c3..795538f1c 100644 --- a/src/tools/lxc/src/main.rs +++ b/src/tools/lxc/src/main.rs @@ -45,12 +45,6 @@ struct Cli { #[arg(long)] experimental: bool, - /// Allow testing-only features that must never run in production, currently - /// `network.proxy.builtinTestServer` (a bundled, deliberately-permissive - /// test HTTP proxy). Distinct from --experimental. - #[arg(long = "allow-testing-features")] - allow_testing_features: bool, - /// Report host backend availability as JSON and exit #[arg(long = "available-backends")] available_backends: bool, @@ -279,7 +273,6 @@ fn main() { }; request.experimental_enabled = cli.experimental; - request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; // ── Telemetry init ────────────────────────────────────────────── diff --git a/src/tools/mxc_darwin/src/main.rs b/src/tools/mxc_darwin/src/main.rs index 885341b22..f017a763a 100644 --- a/src/tools/mxc_darwin/src/main.rs +++ b/src/tools/mxc_darwin/src/main.rs @@ -48,12 +48,6 @@ struct Cli { #[arg(long)] experimental: bool, - /// Allow testing-only features that must never run in production, currently - /// `network.proxy.builtinTestServer` (a bundled, deliberately-permissive - /// test HTTP proxy). Distinct from --experimental. - #[arg(long = "allow-testing-features")] - allow_testing_features: bool, - /// Parse and validate config then exit without executing #[arg(long = "dry-run")] dry_run: bool, @@ -115,7 +109,6 @@ fn main() { }; request.experimental_enabled = cli.experimental; - request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; log_request(&request, &mut logger); diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index 7608ee8ac..be8a0ea9c 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -55,12 +55,6 @@ struct Cli { #[arg(long)] experimental: bool, - /// Allow testing-only features that must never run in production, currently - /// `network.proxy.builtinTestServer` (a bundled, deliberately-permissive - /// test HTTP proxy). Distinct from --experimental. - #[arg(long = "allow-testing-features")] - allow_testing_features: bool, - /// Parse and validate config then exit without executing #[arg(long = "dry-run")] dry_run: bool, @@ -157,7 +151,6 @@ struct Cli { "delete", "containername", "experimental", - "allow_testing_features", "dry_run", "setup_hyperlight", "force", @@ -1390,7 +1383,6 @@ fn main() { let mut request = request; request.experimental_enabled = cli.experimental; - request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; // ── Telemetry init ────────────────────────────────────────────── diff --git a/tests/scripts/run_bwrap_directional_test.sh b/tests/scripts/run_bwrap_directional_test.sh index 1c3f58617..2f3219162 100644 --- a/tests/scripts/run_bwrap_directional_test.sh +++ b/tests/scripts/run_bwrap_directional_test.sh @@ -222,7 +222,7 @@ PROBE local rc=0 local out - out="$("$LXC_EXEC" --experimental --allow-testing-features "$config" 2>&1)" || rc=$? + out="$("$LXC_EXEC" --experimental "$config" 2>&1)" || rc=$? if [ "$rc" -ne 0 ]; then echo "$out" echo "FAIL: reachability probe exited $rc; the enforcement path itself is broken." @@ -270,7 +270,7 @@ run_enforced() { sed -e "s/{{LISTENER_PORT}}/$LISTENER_PORT/g" \ "$REPO_DIR/tests/configs/$config" >"$WORK_DIR/$config" local out - if ! out=$("$LXC_EXEC" --experimental --allow-testing-features "$WORK_DIR/$config" 2>&1); then + if ! out=$("$LXC_EXEC" --experimental "$WORK_DIR/$config" 2>&1); then echo "$out" echo "FAIL: $label (lxc-exec returned non-zero)" exit 1 @@ -351,7 +351,7 @@ cat >"$PORT_CONFIG" <&1); then +if ! PORT_OUT=$("$LXC_EXEC" --experimental "$PORT_CONFIG" 2>&1); then echo "$PORT_OUT" echo "FAIL: directional port narrowing (lxc-exec returned non-zero)" exit 1 @@ -388,7 +388,7 @@ run_parity() { "$REPO_DIR/tests/configs/$config" >"$WORK_DIR/$config" local out local rc=0 - out=$("$LXC_EXEC" --experimental --allow-testing-features "$WORK_DIR/$config" 2>&1) || rc=$? + out=$("$LXC_EXEC" --experimental "$WORK_DIR/$config" 2>&1) || rc=$? printf '%s\n' "$out" >"$WORK_DIR/$label.parity.out" if [ "$rc" -ne 0 ]; then printf '%s\n' "$out" >&2 diff --git a/tests/scripts/run_bwrap_firewall_test.sh b/tests/scripts/run_bwrap_firewall_test.sh index 6f93c8cf1..650ddd1ae 100644 --- a/tests/scripts/run_bwrap_firewall_test.sh +++ b/tests/scripts/run_bwrap_firewall_test.sh @@ -24,7 +24,7 @@ fi echo "Running Bubblewrap firewall test: hostname rule address rejected..." NAME_RC=0 -NAME_OUT=$("$LXC_EXEC" --experimental --allow-testing-features \ +NAME_OUT=$("$LXC_EXEC" --experimental \ "$REPO_DIR/tests/configs/bubblewrap_network_firewall_hostname_rejected.json" 2>&1) \ || NAME_RC=$? if [ "$NAME_RC" -eq 0 ] || @@ -63,7 +63,7 @@ cat >"$WORK_DIR/reachability_probe.json" <<'PROBE' } PROBE PROBE_RC=0 -PROBE_OUT=$("$LXC_EXEC" --experimental --allow-testing-features \ +PROBE_OUT=$("$LXC_EXEC" --experimental \ "$WORK_DIR/reachability_probe.json" 2>&1) || PROBE_RC=$? if [ "$PROBE_RC" -ne 0 ] || ! grep -qF "PROBE_WORKLOAD_STARTED" <<<"$PROBE_OUT"; then echo "$PROBE_OUT" @@ -92,7 +92,7 @@ done echo "Running Bubblewrap firewall test: CIDR allow, deny-wins, and tamper resistance..." FIREWALL_RC=0 -FIREWALL_OUT=$("$LXC_EXEC" --experimental --allow-testing-features \ +FIREWALL_OUT=$("$LXC_EXEC" --experimental \ "$REPO_DIR/tests/configs/bubblewrap_network_firewall.json" 2>&1) || FIREWALL_RC=$? if [ "$FIREWALL_RC" -ne 0 ]; then echo "$FIREWALL_OUT" diff --git a/tests/scripts/run_bwrap_inbound_deny_test.sh b/tests/scripts/run_bwrap_inbound_deny_test.sh index 62a984a58..17996fd6e 100644 --- a/tests/scripts/run_bwrap_inbound_deny_test.sh +++ b/tests/scripts/run_bwrap_inbound_deny_test.sh @@ -141,7 +141,7 @@ cat >"$CONFIG" <<'CONFIG_JSON' CONFIG_JSON echo "Running Bubblewrap inbound default-deny test..." -"$LXC_EXEC" --experimental --allow-testing-features "$CONFIG" >"$WORK_DIR/run.out" 2>&1 & +"$LXC_EXEC" --experimental "$CONFIG" >"$WORK_DIR/run.out" 2>&1 & RUN_PID=$! SANDBOX_NETNS="" diff --git a/tests/scripts/run_bwrap_localnet_test.sh b/tests/scripts/run_bwrap_localnet_test.sh index eb3f337b0..ba081c6ec 100644 --- a/tests/scripts/run_bwrap_localnet_test.sh +++ b/tests/scripts/run_bwrap_localnet_test.sh @@ -40,7 +40,7 @@ assert_rejected() { echo "Running Bubblewrap localnet test: $label..." local out local rc=0 - out=$("$LXC_EXEC" --experimental --allow-testing-features \ + out=$("$LXC_EXEC" --experimental \ "$REPO_DIR/tests/configs/$config" 2>&1) || rc=$? if [ "$rc" = 0 ]; then echo "$out" @@ -73,7 +73,7 @@ assert_rejected "ingress.hostLoopback=allow is refused" \ # network namespace rather than accidentally sharing the host's. echo "Running Bubblewrap localnet test: omitted ingress defaults to deny..." IMPLICIT_RC=0 -IMPLICIT_OUT=$("$LXC_EXEC" --experimental --allow-testing-features \ +IMPLICIT_OUT=$("$LXC_EXEC" --experimental \ "$REPO_DIR/tests/configs/bubblewrap_network_localnet_implicit_deny.json" 2>&1) \ || IMPLICIT_RC=$? if [ "$IMPLICIT_RC" -ne 0 ]; then diff --git a/tests/scripts/run_bwrap_network_proxy_test.sh b/tests/scripts/run_bwrap_network_proxy_test.sh index 6d553c66e..e621ca7d0 100644 --- a/tests/scripts/run_bwrap_network_proxy_test.sh +++ b/tests/scripts/run_bwrap_network_proxy_test.sh @@ -147,7 +147,7 @@ run_one() { shift 2 echo "Running Bubblewrap network proxy test: $label..." local out sentinel - if ! out=$("$LXC_EXEC" --experimental --allow-testing-features "$(render_config "$config")" 2>&1); then + if ! out=$("$LXC_EXEC" --experimental "$(render_config "$config")" 2>&1); then echo "$out" echo "FAIL: $label (lxc-exec returned non-zero)" return 1 @@ -170,7 +170,7 @@ run_rejected() { local label="$1" config="$2" marker="$3" sentinel="$4" local out rc=0 echo "Running Bubblewrap proxy migration rejection: $label..." - out=$("$LXC_EXEC" --experimental --allow-testing-features \ + out=$("$LXC_EXEC" --experimental \ "$(render_config "$config")" 2>&1) || rc=$? if [ "$rc" -eq 0 ] || ! grep -qF "$marker" <<<"$out" || grep -qF "$sentinel" <<<"$out"; then diff --git a/tests/scripts/run_processcontainer_network_loopback_test.ps1 b/tests/scripts/run_processcontainer_network_loopback_test.ps1 index e8d0a7f34..1402b2061 100644 --- a/tests/scripts/run_processcontainer_network_loopback_test.ps1 +++ b/tests/scripts/run_processcontainer_network_loopback_test.ps1 @@ -116,8 +116,10 @@ function Phase-NetworkHostLoopback { -Detail "verdict=$($allow.Verdict); exit=$($allow.Result.ExitCode); neither reached the anchor nor produced a typed rejection" } } else { - Record-Result -Phase 'P8d' -Name 'non-PSEC tier rejects hostLoopback=allow' -Status 'skip' ` - -Detail "hostLoopback='allow' is not supported on this Windows version; verdict=$($allow.Verdict); exit=$($allow.Result.ExitCode); tier=$($Script:ExpectedTier)" + $rejected = Test-WasRejected $allow + Record-Result -Phase 'P8d' -Name 'non-PSEC tier rejects hostLoopback=allow' ` + -Pass $rejected ` + -Detail "verdict=$($allow.Verdict); exit=$($allow.Result.ExitCode); timedOut=$($allow.Result.TimedOut); tier=$($Script:ExpectedTier)" } } finally { & $listener.Stop diff --git a/tests/scripts/run_processcontainer_network_proxy_test.ps1 b/tests/scripts/run_processcontainer_network_proxy_test.ps1 index 74fa0c9cf..44c975b29 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -151,9 +151,13 @@ function Invoke-NetworkProxyAssertions { -Detail "verdict=$($direct.Verdict); WFP scopes egress to the proxy endpoint only" } } else { + # "runtime proxy requests do not fall back because + # AppContainer cannot preserve their peer or host-loopback + # requirements." $rejected = Test-WasRejected $envRun - Record-Result -Phase 'P8e' -Name 'non-PSEC tier rejects runtime proxy (no fallback)' -Status 'skip' ` - -Detail "the identity-less shape requires hostLoopback='allow', which is not supported on this Windows version; rejected=$rejected; exit=$($envRun.Result.ExitCode); tier=$($Script:ExpectedTier)" + Record-Result -Phase 'P8e' -Name 'non-PSEC tier rejects runtime proxy (no fallback)' ` + -Pass $rejected ` + -Detail "exit=$($envRun.Result.ExitCode); timedOut=$($envRun.Result.TimedOut); tier=$($Script:ExpectedTier)" } # --- Model-2 shape requirements, independent of tier. -----------------