[LXC] State-aware sandbox lifecycle - #849
Conversation
…) (AB#62953349) Provision/start/exec/stop/deprovision for the LXC backend, modeled on IsolationSessionRunner; reuses lxc CLI wrappers and one-shot lxc-attach PTY streaming. Registers the lxc wire key in Rust dispatch/parser and SDK state-aware routing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3b78bec0-e139-4cfd-9c10-092ef986d4f4
… + narrow LXC start network type Restrict is_valid_container_name to the same character set and length bound (<=20 chars, alphanumeric/-/_) that NetworkIptablesManager::new uses to derive the per-container iptables chain name. This makes the container-name -> chain-name mapping an identity on valid names, so distinct names (e.g. 'a.b' vs 'ab', or names differing only past the 20th char) can no longer collide onto the same firewall chain and cross-tear-down each other's rules. Narrow LxcStartConfig.network to Omit<NetworkConfig, 'proxy'> so the SDK rejects network.proxy at compile time, matching the Rust runner which rejects it at start (apply_network_policy). Adds Rust + TypeScript tests for both. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…aware_provision.json) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5d2aa5b-7f04-4e4d-83d3-a02efe7020ab
Resolved conflicts: - src/core/lxc/src/main.rs: kept state-aware imports; dropped now-unused ScriptRunner - src/Cargo.lock: took main's lock, reconciled via cargo metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5d2aa5b-7f04-4e4d-83d3-a02efe7020ab
Re-run GitHub Actions after a transient Hyperlight E2E network flake (hyperlight_networking live-HTTP cases timed out after 30s). No source changes; this empty commit only re-fires the pull_request workflows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5d2aa5b-7f04-4e4d-83d3-a02efe7020ab
- Route Lxc state-aware dispatch through mxc_engine::run_state_aware so the lxc binary stays a thin CLI shim instead of hand-rolling the backend match. - Stop/destroy the container before tearing down its iptables rules in stop(), deprovision(), and the start() rollback, discovering the veth first so the FORWARD hook rule can still be deleted after the device is gone. Closes an unrestricted-egress window during teardown. - Clear lxc.mount.entry before reapplying filesystem mounts so a restart with a tightened policy no longer inherits the previous run's bind mounts (new LxcContainer::clear_config_item). - Kill the timed-out child's whole process group and bound the output drain in mxc_pty::run_with_pty so a leaked in-container process holding the pty open can no longer hang exec forever (new join_with_timeout helper). Adds unit/regression tests for each fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve conflict in wxc_common/src/state_aware_dispatch.rs: register both the `lxc` (this PR) and `wsb` (upstream microsoft#578) state-aware backend prefixes in backend_from_prefix, and keep both resolve_backend unit tests. Added `correlation_vector: None` to the lxc test to match the ParsedStateAwareRequest field introduced upstream (microsoft#624). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tate-aware-lifecycle # Conflicts: # sdk/node/src/state-aware-helper.ts # sdk/node/src/state-aware-types.ts # sdk/node/tests/unit/state-aware-types.test.ts # src/backends/lxc/common/src/filesystem_mounts.rs # src/core/wxc_common/src/state_aware_backend.rs
Resolve conflict in src/backends/lxc/common/src/filesystem_mounts.rs as a union of both changes: - microsoft#633 mount-accumulation fix: clear_config_item("lxc.mount.entry") before re-deriving the policy's mounts (so a restart replaces, not unions, mounts). - upstream microsoft#630 denied-dir masking: rebound_container_paths / has_rebound_descendant, iterating &mounts. Both new unit tests (configure_filesystem_mounts_replaces_not_accumulates and has_rebound_descendant_detects_nested_rebind_only) are kept. Validated with `cargo check -p lxc_common --tests` (native linux/liblxc, WSL). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b6b3323b-7297-4b07-9e6e-ab4b220124e6
…art, SDK surface Five review findings, all cases where the state-aware LXC path reported success while enforcing less than the caller asked for. - Hook FORWARD with -i, not -o. Container-originated packets arrive at the host on the host-side veth, so egress matches by input interface. `-o` matched traffic flowing toward the container, so container egress -- the thing the policy exists to restrict -- was never filtered for the whole runtime. The teardown `-D` uses `-i` for the same reason, or the hook leaks; `force_cleanup` shares that path so signal and stop/deprovision cleanup stay consistent. - Stop start() from failing open. `apply_firewall_rules` treats a non-firewall enforcement mode as a successful no-op, and only warns when no veth was discovered. Since `enforcementMode` defaults to `capabilities` and LXC has no capability-based network enforcement, a policy with allowedHosts/blockedHosts/defaultPolicy=block was silently unenforced. Start now rejects that combination, and fails when the veth cannot be discovered in firewall mode. - Narrow LxcNetworkConfig to what LXC actually honors. It was `Omit<NetworkConfig,'proxy'>`, which still exposed `removeRulesOnExit` (SDK-only, and `wire::Network` is `deny_unknown_fields`, so sending it fails the whole request) and `allowLocalNetwork` (deserializes, but the LXC backend never turns it into a rule). `enforcementMode` is restricted to the firewall modes to match the runtime check above. The existing type test asserted the old shape and is updated. - Export the LXC state-aware types from the package entry point. They were missing from sdk/node/src/index.ts, unlike the IsolationSession and WindowsSandbox equivalents, so consumers could not import them. - Document the containerId contract. The API doc said state-aware shapes never carry containerId; LXC provision does. Documents the adopt-or-create behavior and, importantly, that deprovision destroys an adopted container too -- MXC keeps no state between phases, so it cannot tell the two apart. Adds the missing LXC row to the policy-honor matrix. Also applies `cargo fmt`, which fixes the failing format check. Tests: 478 Rust (cargo test -p lxc_common -p wxc_common -p lxc) and 210 SDK (npm test) pass; clippy on the Linux crates is clean; fmt is clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
…xc executors The lxc executor's state-aware entry point called mxc_engine::run_state_aware directly, while wxc's wrapped the same call in telemetry init, backend/phase process attribution, the MS-CV seed/spin plan, the crash panic hook, and the terminal emit_state_aware event. So a Linux lifecycle produced no lifecycle telemetry, carried no correlation vector -- provision returned no cV for the client to relay into later phases -- and installed no crash hook. Every one of those is invisible at the call site, which is how it stayed unnoticed. Moves that orchestration into mxc_engine::run_state_aware_with_telemetry and calls it from both entry points, so the two cannot drift again. Executors keep their own terminal behavior (buffer flush, stdout envelope, exit code); only the observability wrapper is shared. The correlation-vector helpers move with it, along with their six tests -- ported verbatim rather than rewritten, so coverage is unchanged. Also fixes a misleading error from exec_state_aware. LXC has no streaming SandboxProcess, but the fallback arm reported "backend Lxc does not implement the state-aware lifecycle" -- untrue, since run_state_aware dispatches every phase for it, and it points at a provision path that works fine. The message now separates "no lifecycle at all" from "lifecycle but no streaming exec" and names the API that does work. Streaming exec for LXC is still unimplemented; this only makes the gap legible. Tests: 498 Rust on Linux (mxc_engine, lxc, lxc_common, wxc_common), 40 on Windows (wxc, mxc_engine); clippy clean on both; fmt clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
…suite tests/configs/lxc_state_aware_provision.json was checked in but no script ever executed it, and run_lxc_all_tests.sh had no state-aware entry at all -- only one-shot cases. The unit tests stub the container, so nothing exercised provision -> start -> exec -> stop -> deprovision against a real host: a phase that was broken on Linux would still ship green. Both other backends already have such a script (run_isolation_session_state_aware_tests.ps1, run_windows_sandbox_state_aware_tests.ps1); LXC is the odd one out. Adds run_lxc_state_aware_test.sh, which relays the provisioned sandboxId through every later phase the way a real client does, asserts the lxc:mxc- prefix, and checks that exec relays a nonzero script exit code rather than swallowing it. Later phases are generated with the sandboxId injected, matching how the PowerShell suites build requests inline; only provision reads a static config, so the distribution/release stay in one place. sandboxId is extracted with sed rather than jq or python, neither of which is guaranteed on an LXC test host. An EXIT/INT/TERM trap deprovisions on any early failure or signal, since a leaked container outlives the run and breaks the next one; the normal path clears the id first so the container is not deprovisioned twice. Verified against a stub lxc-exec (no LXC host needed): the happy path passes 8/8 with the sandboxId relayed into start/stop/deprovision, a failing phase is counted and exits nonzero without double-deprovisioning, and a SIGTERM mid-run triggers exactly one cleanup deprovision of the right sandbox. bash -n clean, LF endings so the suite's CRLF guard passes, mode 100755. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
The enforceability gate added in the previous commit used default_network_policy == Block as evidence that the caller asked for a restriction. NetworkPolicy::default() *is* Block, so a start with no `network` block at all produces exactly that value alongside the default enforcementMode of `capabilities` -- and was rejected. That breaks every plain start, including the basic lifecycle in run_lxc_state_aware_test.sh, so the backend advertised a lifecycle its own E2E test could not complete. Once the wire `network` block is flattened into ContainerPolicy, an explicitly requested `defaultPolicy: "block"` is indistinguishable from no block at all, so it cannot be the trigger. Gates on the host lists instead, which are empty unless the caller populated them -- the same reasoning has_network_policy already uses to ignore default_network_policy. allowedHosts/blockedHosts under a non-firewall mode are still rejected, which was the actual fail-open. Documents the residual gap rather than hiding it: `defaultPolicy: "block"` alone is not enforced under `capabilities`, and callers who want a default-deny container must set enforcementMode explicitly. Adds a regression test built from ContainerPolicy::default() -- the exact policy a plain start produces -- so this cannot silently come back. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
Two resolutions needed: - src/core/wxc/src/main.rs: upstream kept the correlation-vector helpers and added log_state_aware_dispatch_error next to them; this branch had moved the helpers into mxc_engine so the lxc executor could share them. Kept the move and kept upstream's new helper and its call site. Mirrored the same diagnostic-error routing into the lxc executor, which is the whole point of sharing the orchestration -- upstream improved one entry point and the other would otherwise have drifted again immediately. - src/core/wxc_common/src/state_aware_dispatch.rs: upstream added a source_text field to ParsedStateAwareRequest and updated every struct literal it could see. resolve_backend_for_lxc_prefix_returns_lxc is added by this branch, so it was invisible to that sweep and broke the build after a clean textual merge. Added the field. Tests: 558 Rust on Linux, 43 on Windows, 210 SDK; clippy and fmt clean on both platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
The merge commit c9001a3 swept 1,691 untracked files into the branch: sdk/node_modules (1,615), sdk/dist (52), and sdk/dist-tests (24). They were produced by running npm ci / the SDK build locally to collect test numbers, and none of them are tracked at the merge base (33f3033) or on main. Untracked with 'git rm -r --cached'; the files stay on disk locally. No source change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
The same bad 'git add' in merge commit c9001a3 rewrote this file with CRLF endings. The repo stores it as LF, core.autocrlf is false, and .gitattributes only pins *.sh to LF, so git recorded the flip verbatim and the whole file showed as rewritten: 1707 insertions / 1949 deletions for what is really a 5 insertion / 247 deletion refactor. Converted back to LF. The diff for this file is now identical with and without --ignore-all-space. No source change; cargo check and cargo fmt pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
…tate-aware-lifecycle
configure_filesystem_mounts cleared every lxc.mount.entry line from the container config on each start, which deleted non-MXC baseline mounts the distribution template or the operator had placed there. Tag each MXC-added mount with a marker comment (set_mxc_mount_entry) and reclaim only marker-tagged entries on restart (clear_mxc_mount_entries), leaving foreign lxc.mount.entry lines intact. The generic clear_config_item is retained for other keys and its tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
NetworkIptablesManager::new sanitized and truncated the container name to MXC-<name>, so two containers whose names shared a prefix, or differed only in characters the sanitizer strips, collapsed onto one chain -- tearing down one then flushed and deleted the other's rules. This held even though the name-validation layer bounded lengths, because new() is also reached from the signal-time force_cleanup path with the raw name. Fold a deterministic FNV-1a hash of the full, unsanitized name into the chain name (MXC-<=15 sanitized>-<8 hex>, <=28 chars, within the netfilter limit). Distinct names now always produce distinct chains, independent of caller-side validation. Update the container-name rationale comment accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
remove_firewall_rules deleted the FORWARD jump only when the manager still remembered the veth interface it hooked. A teardown that never learned the veth (signal-time force_cleanup, or a veth that was never discovered) left the jump installed; the chain then stayed referenced and the following -X failed, leaking the whole chain across container lifetimes. Enumerate the live FORWARD chain (iptables -S FORWARD) and delete every rule that jumps to this chain by its -j target, so the hook is removed whatever interface it was scoped to. Parsing is factored into forward_hook_deletions for testability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The network policy was applied *after* container.start(), leaving a window (roughly the container's boot time) in which a container with a deny policy had unrestricted network. The reviewer flagged this as the most serious finding. Move the firewall install ahead of start. iptables accepts an interface name that does not exist yet, so pin a deterministic host-side veth name (lxc.net.0.veth.pair = mxcv<hash>, reusing the chain-name hash and fitting the 15-char IFNAMSIZ limit) in the container config, build the chain and its FORWARD hook against that name, and only then start the container -- the veth comes up already filtered. A firewall-install failure now aborts the start instead of proceeding fail-open, and a failed start tears the rules back down. This removes the post-start veth discovery and wait_for_network from the start path (discovery is still used by stop/deprovision teardown). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A non-dry-run exec streams the container's raw PTY output directly to the executor's stdout during backend.exec(). If the dispatch then returns Err, the JSON error envelope was printed to that same stdout, so a consumer parsing stdout as JSON saw the envelope glued onto the tail of the raw output. Capture whether this run is a streaming exec (Phase::Exec && !dry_run) before parsed is moved into the telemetry-wrapped dispatch, and in the error branch send the envelope to stderr for that case while every other phase keeps stdout as its single client-facing channel. Factor the serialisation into error_envelope_string so the stdout and stderr paths share one builder and the last-resort fallback, mirroring the wxc sibling executor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
trap cleanup EXIT INT TERM ran cleanup twice on a signal: once for the signal handler and once for the EXIT that the shell then fires. The deprovision phase was issued twice for the same sandbox. Guard cleanup with a CLEANED_UP flag so the teardown body executes at most once regardless of how many trapped events fire. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ec gap The state-aware section listed only isolation_session and windows_sandbox and omitted lxc, which the engine dispatches on Linux (non-experimental). Add lxc to both the prose backend-support note and the API-at-a-glance comment, and disclose the one real limitation: streaming exec (execInSandbox / IPty) returns unsupported_phase for lxc, so callers must use the non-streaming execInSandboxAsync. No network policy field shapes are touched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Recover the presence signal the wire carries but the parser discarded, then fix the three coupled default-policy defects a reviewer raised on PR microsoft#633. The wire type `Network::default_policy` is `Option<NetworkPolicy>`, so the config distinguishes an explicit `defaultPolicy: "block"` (`Some(Block)`) from no network block at all (`None`). `config_parser` flattened that `Option` into the non-`Option` `ContainerPolicy::default_network_policy`, whose struct default is already `Block`, erasing the distinction. `ContainerPolicy` is the internal lowered representation, not the wire schema: it is not reachable from `schema_for!(MxcConfig)`, carries no `JsonSchema` derive, appears in no generated SDK binding, and is never serialized across a process boundary, so recovering the bit needs no schema change. Add an additive internal `default_network_policy_present: bool` to `ContainerPolicy`, set by the parser when the wire value was present. The struct's existing `#[serde(default)]` keeps the field backward-compatible. With the bit restored: - `has_network_policy` now honors an explicit default policy, so a config whose only network setting is `defaultPolicy` is recognized as having a policy. - `requires_firewall_enforcement` now returns true for an explicit `defaultPolicy: "block"`, so under a capabilities (non-firewall) mode the start is rejected fail-closed instead of running the default-deny unenforced. - The already-running ("adopted") container path in `start()`, which keys off `has_network_policy`, now returns `already_started` instead of silently reporting success and bypassing the default-deny. The absent case (default-constructed policy, presence bit false) is unchanged: a plain start with no network block is still not rejected. Updates the one test whose premise this change invalidates (`default_policy_alone_does_not_require_firewall_enforcement`, renamed to `explicit_default_block_requires_firewall_but_absent_or_allow_does_not`) to assert the new distinction while keeping the absent-block invariant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Black-box spec tests derived from the chain_name_for / name_hash contract. The implementation file was never read; every assertion traces to a quoted contract clause. Properties covered: * Injectivity (collision-freedom) over a 200+ name adversarial corpus including the two named families from the contract: shared prefix past the truncation point, and names differing only in sanitizer-stripped characters. * Length bound ≤ 28 characters, asserted over the same corpus. * Shape: MXC- prefix + ≤ 15 sanitized chars + - + 8 hex digits. * Determinism: repeated calls return identical results; FNV-1a regression pins lock the hash values across builds. * name_hash covers the full unsanitized name (hashes differ for inputs that sanitize identically). * NetworkIptablesManager::new stores chain_name consistent with chain_name_for. Mutation test results (all caught): 1. Hash zeroed via AND 0 at truncation point — 4 failures. 2. Hash computed over sanitized name (strip non-alnum/dash) — 3 failures. 3. Hash truncated to 4 hex digits — 1 failure. 4. Sanitized segment widened past 15 chars (.take(20)) — 4 failures. 5. FNV prime bumped by XOR 1 — 2 failures. lxc_common test count: 70 → 81 (+11). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Finding 1 (Medium) -- documentation stated the opposite of the code. The paragraph at mxc-state-aware-sandbox-api.md:1616 said defaultPolicy: "block" was indistinguishable from an absent policy and would NOT be enforced under capabilities. e3e657a inverted that: the parser now records default_network_policy_present so an explicit block IS distinguishable, and requires_firewall_enforcement returns true for it. Rewrote the paragraph to describe actual behavior verified from state_aware.rs:158-163 and :227-233. Finding 2 (Low) -- rejection message named only allowedHosts/blockedHosts. A caller rejected solely for defaultPolicy: "block" received a message that mentioned only allowedHosts/blockedHosts. Extended the message to name the explicit default policy as an additional trigger alongside the host lists while keeping the existing voice and error type. Finding 3 (Low) -- parser assignment for the presence bit was untested. Existing parser tests asserted only the flattened NetworkPolicy value, not default_network_policy_present. Added three end-to-end parser tests: absent defaultPolicy -> presence false; explicit "block" -> presence true + value Block; explicit "allow" -> presence true + value Allow. Mutation proof: deleting the single assignment policy.default_network_policy_present = true; from config_parser.rs with anchor count=1 produced 562 passed / 2 failed (block_sets_presence_true and allow_sets_presence_true). Restore confirmed byte-identical. wxc_common test count: 561 -> 564 (+3). lxc_common test count: 81 (unchanged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The iptables chain/veth name derivation truncated the FNV-1a hash to its low 32 bits, so two attacker-chosen container names could collide onto a byte-identical chain (proven: "web-frontend-017m3b" and "web-frontend-01kgar" both -> "MXC-web-frontend-01-3d4a49a5", found in 793,379 candidates). A teardown then flushes and deletes the incumbent container's chain and FORWARD hook, leaving it running with no firewall -- fail-open. Retain the full 64-bit FNV-1a hash and encode it as a fixed 11-char base36 token (hash mod 36^11, ~2^56.9), shared by both names: chain = "MXC-" + <=12 sanitized + "-" + 11 base36 = 28 chars (netfilter) veth = "mxcv" + 11 base36 = 15 chars (IFNAMSIZ) The sanitized allowance shrinks 15 -> 12 to fit the wider token. Determinism is preserved (no RandomState/DefaultHasher) so force_cleanup reconstructs the same names cross-process. Adversarial collision search moves from ~2^32 (sub-second) to ~2^56.9 (infeasible); a 2,000,000-name shared-prefix sweep now yields zero collisions. This is collision-resistant, not injective -- corrected the five places that claimed "always distinct" / "collision-free" / "injectivity" to say so. Tests: converted the collision proof into a regression test, added length-bound, shape, exact-string cross-process determinism, and 64-bit hash pins; renamed the former "injectivity" test to state it checks a near-miss corpus only. All 5 mutants of the derivation are caught. lxc_common 81 -> 88 passed / 0 failed / 0 ignored; wxc_common 564 passed unchanged. No schema files touched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The hash_token comment stated 36^11 = 131_601_804_755_189_760. The correct value is 131_621_703_842_267_136. The code is unaffected -- MODULUS is computed as 36u64.pow(11), so only the comment was wrong -- but a reviewer checking the width argument against the stated number would have found it did not add up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52fb9e8c-4a48-435c-9964-8ec0fee653c6
The doc comment on chain_name_for stated that finding a collision requires ~2^56.9 work and was infeasible to search adversarially. That conflated second-preimage with collision resistance. 36^11 is ~56.87 bits, so the generic birthday work to find some colliding pair is ~2^28.4, and FNV-1a is non-cryptographic - every step is a bijection, so it inverts rather than needing a search. A caller that picks its own containerId can therefore construct a colliding pair and make teardown of one name remove the other's chain. Defending against that needs persisted ownership verification, not a wider hash. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52fb9e8c-4a48-435c-9964-8ec0fee653c6
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/core/wxc_common/src/models.rs:641
- This is a parse-derived presence bit, but unlike its peers it is included when
ContainerPolicyis serialized (for example bydiagnostic::redacted_request_json). Mark it skipped so internal parser state does not silently change the serialized diagnostic shape;filesystem_specified,network_specified, andnetwork_mode_specifiedestablish this convention at lines 627, 658, and 667.
pub default_network_policy_present: bool,
tests/scripts/run_lxc_state_aware_network_test.sh:272
- A failed deprovision is counted as a failure, but the sandbox ID is then cleared unconditionally, so the EXIT trap cannot retry cleanup and the live container can leak into later cases. Keep the ID until deprovision actually succeeds, as the lifecycle test already does in
run_lxc_state_aware_test.sh:170-175.
Start was refused when a container put its only network interface at any index other than lxc.net.0. Nothing in the Linux roadmap asks for that. The requirement was self-imposed: the pin key was the literal string "lxc.net.0.veth.pair", so an interface anywhere else was called unenforceable because the code did not know how to write to it. configured_net_interfaces now reports the sole interface's index along with its type, found by scanning lxc.net.N.type up to NET_INDEX_SEARCH_LIMIT, and the pin is written to lxc.net.<N>.veth.pair. liblxc offers no config dump and rejects "-c lxc.net.N" without a sub-key, so the bounded scan is the only way to recover the index. A container numbering its interface past the bound is still refused, with a message naming the range that was searched. Refusal on a non-veth type is unchanged, and it now names the real index rather than asserting the interface is missing from lxc.net.0. E2E case 10 provisions a container, renumbers its sole interface to lxc.net.3, and asserts the container starts, executes, and has both FORWARD hooks on the veth its own ip link reports. Restricting the scan to index 0 fails case 10 and leaves case 3 green, so the case guards this capability and nothing else. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (5)
sdk/node/src/state-aware.ts:198
- For LXC dry-run execs, the executor keeps stdout as the protocol channel (
exec_streams_stdoutis false insrc/core/lxc/src/main.rs:148), but this branch always inspects stderr. A dry-run validation/deserialization failure is therefore returned as a normalExecResultinstead of a typedMxcError. Select stderr only for non-dry-run LXC execs; dry-run should parse stdout like the other envelope-only paths.
const errorEnvelope = backendKey === 'lxc'
? tryParseErrorEnvelopeFromLines(stderr)
: tryParseErrorEnvelope(stdout);
tests/scripts/run_lxc_state_aware_network_test.sh:274
- A failed deprovision still clears
SANDBOX_ID, so the EXIT trap cannot retry cleanup and the test leaks the container into subsequent runs. Preserve the ID unless deprovision succeeds, as the lifecycle test already does.
src/backends/lxc/common/src/state_aware.rs:270 - This policy gate ignores
policy.ui_specified, so raw state-aware requests can supply a UI lockdown and every LXC phase reports success without enforcing it.ContainerPolicy::uidefaults to deny and the parser deliberately preserves presence, so silently accepting it makes a security policy claim that the backend does not deliver. Reject any supplied UI block withpolicy_validationon start and the other phases, as unsupported-policy backends do.
fn reject_start_policy_on_other_phase(
phase: &str,
policy: &ContainerPolicy,
) -> Result<(), MxcError> {
if has_filesystem_policy(policy) || has_network_policy(policy) {
return Err(MxcError::policy_validation(format!(
"LXC state-aware {phase} does not accept filesystem or network policy; pass it to start"
)));
src/backends/lxc/common/src/state_aware.rs:1005
- The new live matrix verifies only the host-side FORWARD chain. Removing this state-aware
apply_ingress_policycall would leave every added lifecycle test green, even though the PR's key behavior is installing INPUT policy inside the container namespace. Add a state-aware live assertion that the MXCI chain and INPUT hook exist in the provisioned container's namespace (similar torun_lxc_inbound_deny_test.sh).
// Inbound enforcement lands only once the container's network
// namespace exists, so unlike the egress chain it comes after start.
if let Err(e) = apply_ingress_policy(&container, container_name, request, &mut logger) {
src/backends/lxc/common/src/signal_cleanup.rs:124
NetworkOnlyalways runsStopContainerbefore cleanup. A signal can arrive after the FORWARD chain is installed but beforecontainer.start(); in that statelxc-stop -kreturns nonzero for the already-stopped container,execute_rollbackaborts, and the newly installed chain is stranded. Distinguish an already-stopped container from a failed stop so pre-start cancellation can still remove the owned firewall while preserving the fail-closed behavior for an unknown/live container.
SignalRollback::NetworkOnly => {
plan.push(RollbackStep::StopContainer);
if owns_firewall {
plan.push(RollbackStep::RemoveFirewall);
}
An independent review of 825334a found the index scan could fail open. The interface count and the interface index come from separate lxc-info runs, so a config rewritten between them is pinned against a set of interfaces that no longer exists: counted with one interface, scanned, then a second interface arrives, and the container starts with only the first one hooked while the second routes freely. The window is not new -- the previous code read lxc.net and then lxc.net.0.type through the same two separate runs -- but the scan widened it, and it is closable. configured_net_interfaces now brackets the scan with a second read of lxc.net and refuses when the two do not match. liblxc offers no lock and no atomic config read, so a summary that did not survive the scan is the strongest available evidence that the index cannot be trusted; refusing is the only answer that fails closed. resolve_scanned_interface holds the decision as a pure function, matching the file's existing split between subprocess wrappers and testable parsers. Six unit tests cover it, including the two that must NOT refuse: a stable config with nothing found in range, and a trailing-newline difference that is subprocess capture noise rather than a config change. Defeating the comparison fails exactly the three refusal tests and leaves the three pass-through tests green. Also corrected a comment in state_aware.rs that still said only lxc.net.0 receives a pinned veth, and made case 10's config path honor LXC_PATH the way resolve_lxcpath_with_env does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
sdk/node/src/state-aware.ts:198
- LXC dry-run exec failures are emitted on stdout (
exec_streams_stdoutis false whendry_run), but this branch always inspects stderr. ConsequentlyexecInSandboxAsync(..., { dryRun: true })returns an ordinary nonzeroExecResultinstead of throwing the typed validation error. Select stderr only for non-dry-run LXC execs.
const errorEnvelope = backendKey === 'lxc'
? tryParseErrorEnvelopeFromLines(stderr)
: tryParseErrorEnvelope(stdout);
src/backends/lxc/common/src/signal_cleanup.rs:101
- This safety explanation contradicts the implemented rollback plan: the one-shot path no longer explicitly removes ingress rules before destroy; destroy removes their network namespace. The later
DestroyContainercomment states the opposite correctly. Update this paragraph so future ordering changes are not based on the obsolete behavior.
/// The inbound rules are different in kind. They live inside the container's
/// own network namespace, reachable only by entering it through the init PID,
/// so they cease to exist when the container does. That is why only the
/// one-shot path removes them, and why it does so before `destroy` -- after
/// that there is no namespace left to enter. The stop path deliberately omits
tests/scripts/run_lxc_state_aware_network_test.sh:272
- The sandbox ID is cleared even when deprovision fails, so the EXIT trap cannot retry cleanup and the failed case can leak a container into subsequent runs. Retain the ID unless deprovision succeeds, matching the lifecycle test's cleanup behavior.
src/core/wxc_common/src/models.rs:641 - This parse-derived presence bit is the only such
ContainerPolicyfield not marked#[serde(skip)](unlikefilesystem_specified,network_specified, andnetwork_mode_specified). Serializing the publicExecutionRequesttherefore exposes it, and deserializing can accept a caller-supplied value, contradicting the documented “never on the wire” contract.
pub default_network_policy_present: bool,
| let sole = if count == 1 { | ||
| let located = self.locate_sole_net_interface()?; | ||
| let recheck = self.query_config_item("lxc.net")?; | ||
| resolve_scanned_interface(&summary, &recheck, located)? |
The review of 1fbc2a9 showed the bracketing guard did not do what its own comment claimed. lxc.net prints interface values, not indices, so the two sequences that matter both passed it: an interface moving from lxc.net.0 to lxc.net.3 leaves the summary byte-identical while invalidating the located index, and a macvlan that reads as veth only while the scan runs leaves the summary identical while taking a veth pin it can never answer to. Either one starts a container whose traffic never reaches the chain. The recheck now covers the key actually depended on. After the scan, lxc.net.<located>.type is read back and required to still be the type the scan found, and the summary comparison is kept because the two catch different things: the summary catches an interface arriving, and the type catches the located one leaving. Defeating either check fails only its own tests, so neither is carrying the other. The doc comment no longer claims this closes the window. liblxc offers no lock and no atomic config read, so a writer that restores the config before the recheck still passes -- that writer needs write access to a root-owned config and could flush the firewall directly, so it is not the case being defended against. The case being defended against is a config that moved and stayed moved, which would otherwise be pinned wrong in silence. Two of the earlier tests passed for the wrong reason: both asserted Ok(None), which an implementation that never refuses also returns. One is renamed to what it actually pins -- a scan that found nothing is not a read failure -- and the other now carries a located interface so it proves pass-through rather than absence. Also corrected deterministic_veth_name's doc comment, which still said the pin goes to lxc.net.0.veth.pair. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/backends/lxc/common/src/state_aware.rs:1005
- The live state-aware matrix exercises the new egress path only: its default-deny assertions inspect host
FORWARDhooks and the terminalDROP, but nothing verifies that this state-aware call installs the container-netnsINPUTchain or rolls back a failed ingress apply. The existing inbound test covers the one-shot runner, so this wiring could be removed while the new lifecycle suite remains green. Add a live state-aware inbound assertion (and a failed-apply rollback case) before relying on this security boundary.
if let Err(e) = apply_ingress_policy(&container, container_name, request, &mut logger) {
tests/scripts/run_lxc_state_aware_network_test.sh:274
- If deprovision fails, this still clears
SANDBOX_ID, so theEXITtrap cannot retry and the test leaks the container it just reported failing to remove. Keep the ID until deprovision succeeds, as the lifecycle test already does.
src/core/wxc_common/src/models.rs:641 - This is documented as parse-derived and “never on the wire,” but unlike every adjacent presence bit it lacks
serde(skip). Serializing the publicContainerPolicytherefore exposesdefault_network_policy_present, and deserialization accepts callers setting this internal parser fact. Skip it so onlyconvert_wire_configcan establish presence.
pub default_network_policy_present: bool,
…ndex
Locating the sole interface by scanning lxc.net.<N> required a bound, and
liblxc offers nothing to bound it with: get_keys("lxc.net.") returns the
schema subkeys rather than the configured indices, no API dumps the merged
config, and any index from 0 to INT_MAX-1 is valid. The scan therefore had
to invent a limit and refuse every container above it, which only moved the
hardcoded index from 0 to 31.
Pin the veth from a container-global lxc.hook.start-host instead. The hook
resolves the peer interface from LXC_PID and renames it, so enforcement no
longer reads an index at all and the count and kind both come from the one
non-indexed lxc.net read. That single read also removes the two-call race
the earlier review found, rather than guarding it. A hook that cannot find
its interface exits nonzero and aborts the start, so the failure stays
closed.
Teardown then has to stop asking liblxc for the interface name. The hook
renames the interface after liblxc created it, so lxc-info keeps reporting
the name it generated, and deletes replayed against that stale name matched
nothing: stop left the chain hooked and failed. The name is derived from
the container name now, which is what put it there.
Deriving it exposed a second defect. observe_existing recorded only the
plain hook, justified by teardown enumerating every form -- true only while
the authoritative path never had an interface to replay. It now recognizes
each rule by the match it carries rather than by its text, because iptables
prints back RELATED,ESTABLISHED for a rule installed as ESTABLISHED,RELATED
and a rebuilt spec would miss every return rule. With the interface known,
teardown reaches the two return rules that were previously a known strand,
and deprovision now leaves nothing behind.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (3)
sdk/node/src/state-aware.ts:198
- For LXC dry-run execs,
lxc-execdoes not reserve stdout for guest output:exec_streams_stdoutis false whendry_runis set, so dispatch errors are emitted on stdout. This branch always checks only stderr for LXC, causingexecInSandboxAsync(..., { dryRun: true })to return an ordinary nonzero result instead of throwing the typed dispatch error. Select stderr only for non-dry-run LXC execs.
const errorEnvelope = backendKey === 'lxc'
? tryParseErrorEnvelopeFromLines(stderr)
: tryParseErrorEnvelope(stdout);
tests/scripts/run_lxc_state_aware_network_test.sh:390
- The live state-aware default-deny check verifies only the host
FORWARDhooks and terminal DROP. It never enters this started container's network namespace to assert that the new state-aware path installed and hooked itsINPUTchain; the existing inbound script exercises the one-shot path instead. A regression that omitsapply_ingress_policyfrom state-aware start would therefore leave all these cases green despite inbound being open. Add a namespace-level INPUT assertion for this case (and IPv6 when active).
tests/scripts/run_lxc_state_aware_network_test.sh:274 - These state resets run even when stop or deprovision fails. In that case the EXIT trap no longer retries the failed phase, so this root-run test can leak a running container and its firewall state into later cases or test runs. Clear each flag only after the corresponding phase succeeds, as the lifecycle test already does for deprovision.
The redesign that replaced the indexed veth pin with a container-global start-host hook made teardown derive the interface name instead of reading it back. That is correct for the state-aware path, and a review of the pushed commit found three places where it is not. A one-shot container is renamed too. The hook is written into the container's durable config and stop never clears it, so a container that was started state-aware and is later handed to the one-shot runner by name is renamed on start while liblxc keeps reporting the name it recorded before the rename. Rules scoped to that recorded name filter an interface that no longer answers to it, which leaves the container unfiltered. The runner now resolves the live name: it prefers the deterministic name when the host confirms an interface by it, and falls back to what liblxc recorded otherwise. An unreadable FORWARD claimed only the plain hook. Deriving the name routes teardown down the branch that deletes exactly what it believes it owns, so a blind delete of the plain hook could succeed, leave no hook recorded, and let the chain be flushed while an unobserved physdev hook still jumped into it. An emptied chain that is still hooked returns to its caller instead of reaching its own closing DROP. Both hook forms are now claimed. A delete for a rule that never existed fails and is held as a residual, which reports the stop as failed and retries -- the losing side of a trade whose other side is an unfiltered container. An interface-scoped ACCEPT is not ours on the interface name alone. A host rule accepting everything on the same interface was claimed, and teardown then submitted this backend's fuller specification against it, a delete that cannot match and is reported forever as a residual. The classifier now requires the connection-state match, compared as a set because iptables prints RELATED,ESTABLISHED for a rule submitted as ESTABLISHED,RELATED. Three tests pin these: the two hook forms and the two return forms are each claimed independently, and an ACCEPT carrying no connection-state match is claimed by neither. Each was mutation-tested and each kills its mutant. 431 unit tests pass, the network matrix is 29/0, the lifecycle suite 8/0, and the aggregate 82/0. A full manual lifecycle leaves zero residue. The stale comment in the network matrix described the deleted pin and the physdev hook as bridged-only, which was never true. The assertions are untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/scripts/run_lxc_state_aware_network_test.sh:272
- A failed deprovision is counted but then
SANDBOX_IDis cleared. The next case can overwrite the only handle to that container, so the EXIT trap cannot retry cleanup and the test leaks a container. Abort while retaining the ID when deprovision fails; only clear it after success.
src/core/wxc_common/src/models.rs:641 - This parse-derived presence bit is serialized by
ContainerPolicy, even though its documentation says it is never on the wire. The neighboringfilesystem_specified,network_specified, andui_specifiedfields are all skipped; leaving this one exposed leaks internal parser state into serialized policies and makes round-trips inconsistent. Mark it with#[serde(skip)]as well.
pub default_network_policy_present: bool,
…ore flushing A second review found the previous commit's three fixes each stopped one step short. All three shared a shape: a cheaper signal was standing in for the question actually being asked. Interface resolution asked the host, which cannot answer. Whether an interface of the pinned name exists on the host says the name is taken, not that this container took it, and a transient failure of that probe answered "no" and sent enforcement back to the name liblxc recorded -- the stale one, which is the defect the probe was added to close. The question belongs to the container: it carries the pin hook or it does not, and a hook that cannot rename aborts the start, so the answer is decisive. `has_veth_pin_hook` reuses the comparison `ensure_veth_pin_hook` writes with, so the reader and the writer cannot drift. The host probe is deleted rather than repaired -- it was answering the wrong question. A config that cannot be read is now an error instead of a guess between two names, one of which filters nothing. The flush gate trusted a delete's exit code. A `-D` that succeeds removes one matching rule; iptables holds duplicates, and a jump can carry qualifiers this backend never writes and still reach the chain. The recovery path reconstructs ownership for a container whose state was lost, which is where a second jump is likeliest to have accumulated, so this is reachable rather than theoretical. FORWARD is now re-read before the flush, the same authority the enumerating path already answered to. That re-read is strictly inhibitory: it may set a hook bit, never clear one. Written the other way it broke an existing test that fails a delete and requires the chain not be flushed -- correctly, because an authoritative re-read overrules a delete that genuinely failed on the word of a probe that can be a moment stale. The test was right and was not touched. The connection-state guard still claimed foreign rules. A rule can carry the interface, the state match, and a narrowing this backend never writes -- a protocol, an address, a port, a negation -- and the delete rebuilt from our own specification would not name it, so it could not match and would be held forever as a residual. Anything outside the vocabulary these rules are built from is now somebody else's rule. Two tests pin the new behavior and each kills its mutant; the inhibitory-versus-authoritative gate is pinned by the existing test that caught it. 432 unit tests pass, the network matrix is 29/0, the lifecycle suite 8/0, and the aggregate 82/0, which covers the one-shot path that now asks the container. A full manual lifecycle leaves zero residue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/backends/lxc/common/src/signal_cleanup.rs:405
- A signal can arrive after the FORWARD chain is created but before
container.start(). In that statelxc-stop -kreports that the container is not running, this step returnsfalse, andexecute_rollbackdeliberately skipsRemoveFirewall, stranding the chain and blocking the next start. Treat a positively stopped container as a successful stop; preserve the current fail-closed behavior when the state probe is unknown or killing a live container fails.
RollbackStep::StopContainer => LxcContainer::new(&name, None).kill().is_ok(),
tests/scripts/run_lxc_state_aware_network_test.sh:272
- This clears
SANDBOX_IDeven when deprovision fails. The next case then overwrites the only identifier the EXIT trap could retry, leaking the live container and potentially its firewall state. Stop the matrix on teardown failure while leaving the ID armed so the trap gets one final cleanup attempt.
| // The executor process exits right after a (state-aware) exec, so | ||
| // the OS reaps the detached thread. | ||
| let _ = join_with_timeout(output_thread, DRAIN_GRACE); |
The network matrix covered defaultPolicy, enforcementMode firewall, and allowedHosts. Six start-phase fields the backend decides on had no case at all, so a build that stopped honoring any of them would have gone unnoticed: enforcementMode=both, an accepted allowedHosts, blockedHosts, allowLocalNetwork, proxy, and the filesystem lists. Cases 11 through 16 close that. Each names the roadmap clause it pins: item 13 (N1) for default-deny under `both`, item 15 (N3) for an enforceable allowedHosts, item 16 (N4) for blockedHosts, item 14 (N2) for rejecting allowLocalNetwork rather than guessing, item 17 (N5) for refusing a proxy this backend cannot enforce, and D1/D4 for the filesystem lists the start phase accepts and provision refuses. Case 16 reads the filesystem result from inside the container rather than from a zero exit: the host sentinel is readable through the readonly bind, a write to it fails, and the denied directory is masked empty. The proxy case needs both wire forms. A state-aware start may not carry `containment`, so the shared parser applies its backend-specific rules under the default backend and refuses an external proxy url before any LXC code runs. The builtin form skips that rule but is testing-only scaffolding gated centrally, so it needs --allow-testing-features to reach the LXC verdict. The case asserts that verdict by its message; accepting any non-zero exit would have passed on the central gate alone, which is exactly what it did until mutation testing caught it. The existing fixture drift guard could not fail the run -- the script does not set -e, so a failing python3 guard was ignored. Both guard blocks now check their exit status. All six new cases are mutation-tested: removing each behavior they pin flips exactly that case and no other. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af
ContainerPolicy::filesystem_specified recorded whether the caller sent a
`filesystem` block at all, so an empty `filesystem: {}` counted as a supplied
policy and the phases that document "no filesystem section" refused it.
Nothing needed that distinction. The three peer backends already gate on the
path lists alone -- isolation_session/common/src/policy.rs:82, windows_sandbox/
lifecycle/src/state_aware.rs:212, and wslc/common/src/policy.rs:105 -- and LXC
was the only backend keying off presence, so removing the bit makes it agree
with its siblings rather than diverge from them.
has_filesystem_policy now answers from readwrite_paths, readonly_paths, and
denied_paths. An empty block is therefore indistinguishable from an absent one
and is accepted at any phase, so the phase matrix and the case 8 wording say
"non-empty path list", and the E2E case that pinned the empty-block refusal is
removed along with the bit it was pinning.
The unit test asserting the empty block was still seen is replaced by one that
exercises each path list on its own, so a clause dropped from the OR is still
caught.
Verified: cargo test -p wxc_common -p lxc_common under WSL, 1142 passed and 0
failed; cargo check --workspace --all-targets on Windows, exit 0; rustfmt and
bash -n clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d01f4daa-736b-48a4-9c78-2ed406f85c08
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 48 out of 49 changed files in this pull request and generated no new comments.
Suppressed comments (3)
tests/scripts/run_lxc_state_aware_network_test.sh:314
- This clears
SANDBOX_IDeven when deprovision fails, so the EXIT trap cannot retry and the failed case leaks its container into subsequent cases/runs. The lifecycle test already preserves the ID on failure for this reason; do the same here.
tests/scripts/run_lxc_state_aware_network_test.sh:434 - The
assert_default_denypath verifies only the host-side FORWARD chain. If the newapply_ingress_policycall were removed, cases 3/10/11/12 would still pass, so the state-aware INPUT-chain behavior claimed by this suite is not covered. Add a container-netns iptables assertion or an inbound traffic probe that fails when the INPUT hook/terminal DROP is absent.
sdk/node/src/state-aware-types.ts:158 FilesystemConfigalso exposes the SDK-onlyclearPolicyOnExitfield, but state-aware envelopes forwardfilesystemunchanged and Rust'swire::Filesystemrejects that field viadeny_unknown_fields. As written, a type-correct LXC start can therefore fail during parsing. Narrow this property to the three actual wire fields, just asLxcNetworkConfigexcludes its SDK-only field.
filesystem?: FilesystemConfig;
LXC used network.enforcementMode to decide whether to enforce network policy at all. The mode defaults to "capabilities", so a start carrying defaultPolicy: block, allowedHosts, or blockedHosts parsed those fields, installed nothing, and returned success. The caller was told the sandbox was network-restricted while the container kept unrestricted egress. No other Linux backend works this way. Bubblewrap and Seatbelt enforce from the policy and treat the mode as a hint, and WSLC never reads the mode at all. LXC was the only backend where a field describing how to enforce could switch enforcement off. Enforcement is now decided by ContainerPolicy::requires_firewall(): a block default, a non-empty allowed or blocked host list, or an enabled proxy. The struct default for default_network_policy is already Block, so a request with no network section is a deny-all. Omitted and empty both deny. enforcementMode is still parsed and accepted, and an explicit "capabilities" is accepted rather than rejected, so a caller can get more enforcement than asked for and never less. Ingress stays unconditional while egress is policy-gated. The asymmetry is deliberate: firewall mode with defaultPolicy allow and no host lists installs an inbound chain today, and gating ingress on the same predicate would silently remove it. Deliberate behavior changes: - A start with no network section installs a deny-all egress chain instead of nothing. - proxy no longer requires firewall or both. LXC refuses network.proxy outright as unsupported, so the mode rule sat on top of an unconditional refusal and could never be the reason a request failed. - A start against an already-running container returns already_started whenever the policy requires enforcement that cannot be applied to a running container, which is what the documented error code means. - A missing init PID is fatal on every path rather than only when a firewall mode was named. default_network_policy_present is deleted. It recorded whether the caller wrote a network section, and no downstream consumer produced a different result from it. Closes microsoft#890. Verified on live LXC containers: 70 passed, 0 failed. A start with no network key and a start with an empty allowedHosts list both end in a FORWARD chain that hooks the container veth on the physdev and direct paths and terminates in DROP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 53 out of 54 changed files in this pull request and generated no new comments.
Suppressed comments (3)
sdk/node/src/state-aware-types.ts:126
- This contract contradicts the PR description, which says restrictive LXC start policies require
enforcementMode: 'firewall'(or'both'). The implementation and tests instead accept omitted/capabilitiesmodes and ignore the field. Please align the PR requirements with the shipped API, or restore the stated validation; currently the intended public behavior is ambiguous.
* - `enforcementMode` is accepted, parsed, and ignored. LXC enforces from
* `defaultPolicy`, `allowedHosts`, and `blockedHosts` alone, so no
* combination of this field with those is worth rejecting here.
sdk/node/src/state-aware-types.ts:143
FilesystemConfigalso exposes the SDK-onlyclearPolicyOnExitfield, butbuildStateAwareEnvelopecopies it into the top-levelfilesystemobject and Rust'swire::Filesystemrejects unknown fields. A type-valid LXC start using that option therefore fails at runtime. Narrow this property to the three wire path lists, as was already done forLxcNetworkConfigto exclude SDK-only fields.
filesystem?: FilesystemConfig;
tests/scripts/run_lxc_state_aware_network_test.sh:425
- The live state-aware matrix only inspects the host
FORWARDchain. It never verifies the new container-namespaceINPUThook or terminal DROP, so removing theapply_ingress_policycall would still leave this suite green—the exact regression this PR is meant to prevent. Add a state-aware assertion that enters the provisioned container's network namespace and checks the INPUT chain (ideally with traffic-level IPv4/IPv6 probes).
`tryParseErrorEnvelopeFromLines` was an exported symbol on
`state-aware-helper.ts`, which is one of the paths that auto-requests
Mxc-Architect-Team review. What it held was a single expression: take the
last non-empty line of stderr and hand it to `tryParseErrorEnvelope`. The
loop read as a search, but it returned unconditionally on the first non-empty
line from the end, so the `continue` only ever skipped the trailing blank that
`split('\n')` produces because the executor ends its output with a newline.
It had exactly one caller, was not re-exported from `index.ts`, and no test
imported it -- the three tests that pin this behavior all drive it through
`execInSandboxAsync`. So the export bought a new public name on a contract
surface and nothing else.
Worse, the name was already taken. `sandbox.ts` has had a private
`tryParseErrorEnvelopeFromLines` since the SDK folder was created, and it does
something different: it scans forward for the *first* parseable envelope
anywhere in a combined PTY stream and returns an `MxcError`. Two
architect-owned files now disagreed about what that name means.
The reasoning that was load-bearing has moved to the call site, which already
carried the channel-ownership argument. The rest of the docstring restated
what the call site says.
No behavior change: `tryParseErrorEnvelope` already trims its input and already
returns null on anything that is not a well-formed envelope, so empty stderr,
envelope-only, buffer-then-envelope, envelope-then-guest-output, and trailing
blank lines all resolve exactly as before. Node SDK builds clean and all 286
tests pass, including the four that pin this path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 53 out of 54 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
tests/scripts/run_lxc_state_aware_network_test.sh:305
- Do not discard the sandbox ID when deprovision fails. Clearing it here disarms the EXIT trap's retry and leaks the container/firewall state; the lifecycle test correctly retains its ID on failure. Capture the return code and clear
SANDBOX_IDonly after success.
src/backends/lxc/common/src/state_aware.rs:948 - The live state-aware matrix never verifies this newly wired ingress half: it inspects only host
FORWARDrules, whilerun_lxc_inbound_deny_test.shexercises the one-shot path. Removing this call would leave the new matrix green. Add a state-aware check that enters the started container's netns and confirms the MXCI chain is hooked fromINPUT(for both usable families).
// Inbound enforcement lands only once the container's network
// namespace exists, so unlike the egress chain it comes after start.
if let Err(e) = apply_ingress_policy(&container, container_name, request, &mut logger) {
| // Ingress installs unconditionally, so the init PID is mandatory for | ||
| // every start: LXC enters the container's netns through it, and the | ||
| // ingress manager cannot even be constructed without one. |
The cross-backend contract reserves stdout for the response envelope in
every phase, including a failed exec: mxc-state-aware-sandbox-api.md
section 7.3 states "stdout is authoritative: for exec it carries either
the script's output (success) or exactly one envelope (failure)", and
section 9.4 says an Err "write[s] the JSON to stdout". stderr is
informational and carries only the diagnostic buffer.
The LXC executor did not follow that. It computed exec_streams_stdout
and rerouted the envelope to stderr for a streaming exec, on the
reasoning that the PTY relay had made stdout an unclean JSON channel.
That symbol existed in exactly one file in the repo. Everything
downstream was scaffolding holding it up: the SDK needed a
backendKey === 'lxc' branch reading only the last non-empty line of
stderr, and the state-aware parse arm emitted the envelope twice so
either kind of reader would find it.
wxc, the other state-aware executor, does none of this, and its own
comment says the lxc entry point is meant to share the orchestration
"verbatim -- a Linux lifecycle is observed exactly like a Windows one,
and the two cannot drift apart". run_state_aware_main is now
structurally identical to wxc's.
The lifecycle E2E already documented the contract LXC was not honoring:
run_lxc_state_aware_test.sh calls the executor with no redirection under
a comment reading "Envelope goes to stdout, diagnostics to stderr, so
the caller can parse stdout directly".
Behavior changes, both shared with every other backend:
- A guest that prints an {error} document and exits nonzero is now
surfaced to the caller as that error. LXC alone had hardened against
this; the contract accepts it, and the script is the caller's own.
- A script killed by its timeout after streaming output returns an
ExecResult rather than throwing, because stdout then holds guest
output and the envelope and whole-string parsing matches neither.
This is a gap in section 7.3 shared by all backends, pinned by a
characterization test so closing it has to be deliberate.
Five LXC-specific SDK tests collapse to two: one proving channel
separation, one recording that gap.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff
…ifecycle-current # Conflicts: # .github/copilot-instructions.md
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (4)
sdk/node/src/state-aware.ts:111
- This channel description contradicts the new executor and the implementation below:
lxc-execwrites dispatch error envelopes to stdout (src/core/lxc/src/main.rs:166), andexecInSandboxAsyncparses stdout at line 174. The streaming API also exposes the PTY's combined data rather than selecting a backend-specific error channel. Please document stdout here so callers do not look for LXC protocol errors on stderr.
* On dispatch failure the executor emits a single error envelope; the SDK does
* not parse it here — callers consuming `IPty.onData` see the raw bytes. The
* channel is backend-specific: LXC puts it on stderr, because stdout is
* carrying the container's raw output, while Windows Sandbox, IsolationSession,
* and WSLc keep stdout as the executor's own channel and emit it there. Use
* `execInSandboxAsync` when typed-error throwing is needed — it already selects
* the right channel per backend.
sdk/node/src/state-aware.ts:153
- This still claims LXC errors are read from stderr, but the changed LXC entry point emits the envelope on stdout and this function only calls
tryParseErrorEnvelope(stdout). Correct the public contract to match the actual parser.
* field set) when the executor reports a dispatch failure, recognised by
* exit != 0 together with a complete `{error}` envelope on the channel that
* backend's executor owns -- stderr for LXC, stdout for the others.
tests/scripts/run_lxc_state_aware_network_test.sh:305
- This clears the only cleanup handle even when deprovision fails. The next case overwrites
SANDBOX_ID, so the EXIT trap cannot retry and the leaked container can contaminate later cases. Keep the ID and stop the matrix on teardown failure so the trap gets one final retry.
.github/copilot-instructions.md:186 - “In a firewall mode” is now incorrect: this PR deliberately ignores
enforcementModeand installs egress filtering wheneverrequires_firewall()is true, including the default-deny request with no network section. Since this file defines repository guidance, describe the policy-driven predicate instead.
| LXC | `lxc-exec` | Linux | `core/lxc/src/main.rs` + `backends/lxc/common/` — supports both **one-shot** (single-invocation lifecycle) and **state-aware** (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. State-aware lives in `backends/lxc/common/src/state_aware.rs` (`LxcStateAwareRunner`) and needs **no daemon**: the container outlives each phase process in the LXC runtime, so `sandboxId` is just `lxc:<containerName>` and every phase re-acquires the handle with `LxcContainer::new`. In a firewall mode the host-side veth name is derived from the container name and pinned by a container-global `lxc.hook.start-host` installed **before** start, so the iptables FORWARD chain is scoped before the interface exists; the hook resolves the container's peer interface from `$LXC_PID` and renames it to that name, and exits nonzero — aborting the start — if it cannot find one. The interface set is read from liblxc (`lxc-info -c`) rather than by parsing the container's config, so an `lxc.include` that declares interfaces elsewhere is resolved rather than refused, and no interface *index* is read at all, so a container numbering its interface `lxc.net.3` is enforced exactly as one using `lxc.net.0`. Start is refused when the container declares anything other than exactly one interface, or does not declare that interface's type as `veth` (an undeclared type is refused too, since absence is not evidence of a veth). Teardown derives that same name rather than asking `lxc-info`, which keeps reporting the name liblxc recorded before the hook renamed it, and is ownership-scoped, so a concurrent start's chain is left alone; mount cleanup clears only MXC-marked `lxc.mount.entry` lines. Inbound default-deny (`IngressManager`) is installed on both paths; on the state-aware path it goes on after `container.start()` because the chain lives in the container's network namespace and needs its init PID, and a failure there rolls the start back. Start, stop, and deprovision each take a per-sandbox `flock` in the LXC root, so a teardown cannot strip a start's firewall in the window before the container runs. See `docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md`. |
…ents LxcNetworkConfig exists to narrow the shared network section to the fields an LXC caller can usefully set, and it was carrying one its own docstring called "accepted, parsed, and ignored". No LXC code reads it. The only production reads of network_enforcement_mode in the tree are the Bubblewrap and Seatbelt proxy guards (config_parser.rs:1178 and :1197); every other occurrence is the write from the wire (:1106) or sits after a #[cfg(test)] marker. The live network matrix shows the same thing as output: cases 2, 3, and 11 differ only in enforcementMode -- omitted, firewall, and both -- and all three end in the same DROP chain. Seatbelt is the contrast that makes the case. It ignores the mode for enforcement too, but still rejects proxy plus a firewall mode, so the field changes what a caller sees. LXC's equivalent guard is gone, so for LXC the field now changes nothing at all. A setting with no effect is worse than no setting. The wire still accepts the field, so a network object shared with another backend keeps parsing and the checked-in configs that carry it are untouched. Two doc comments in state-aware.ts still described the error envelope as arriving on stderr for LXC. That stopped being true when the executor moved to stdout, and one of them contradicted a comment three lines below it in the same function. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
tests/scripts/run_lxc_state_aware_network_test.sh:743
- This case never verifies that
allowedHostsproduces an ACCEPT rule: it only checks that the chain is hooked and ends in DROP, which is identical to default-deny with the allow list ignored. Add a positive connectivity check to a controlled allowed endpoint (plus a denied control), or inspect the destination-specific ACCEPT rule; case 12 has the same gap.
tests/scripts/run_lxc_state_aware_network_test.sh:305 - A failed deprovision still clears
SANDBOX_ID, so the EXIT trap cannot retry cleanup and the container leaks into later cases/runs. Preserve the ID unless deprovision succeeds, as the lifecycle test already does intests/scripts/run_lxc_state_aware_test.sh:168-175.
tests/scripts/run_lxc_state_aware_network_test.sh:793 - This blocklist assertion is vacuous because omitting
defaultPolicyinherits default block, soevil.example.comis denied even ifblockedHostsis completely ignored. Exercise the list underdefaultPolicy: allowand verify both the blocked destination and an allowed control, or inspect the explicit destination DROP rule.
| rm -rf "$FS_RO_DIR" "$FS_DENIED_DIR" | ||
| mkdir -p "$FS_RO_DIR" "$FS_DENIED_DIR" || fail_now "case $case_no could not create its host fixture directories" | ||
| echo "$FS_SENTINEL" > "$FS_RO_DIR/sentinel" | ||
| echo "$FS_SENTINEL" > "$FS_DENIED_DIR/sentinel" |
LXC enforces network policy from the policy itself and the default is deny-all, so every LXC run now installs an egress chain even when the request carries no network section. A bridged veth only reaches FORWARD while br_netfilter delivers bridged packets to iptables, and the SDK integration lanes never enabled it, so the backend correctly refused to report success for a policy it could not enforce: Container veth vethfPaRw9 is attached to a bridge but bridged packets are not delivered to iptables (/proc/sys/net/bridge/bridge-nf-call-iptables is absent or 0), so chain MXC-lxc-pro-... could never be reached from FORWARD. Every LXC test failed at the backend probe with exit 255, across all three schema versions. The dedicated LXC E2E workflow already does this (lxc-e2e.yml:44-48) and is green, so this is the same two sysctls on the lane that was missing them. Its `iptables -P FORWARD ACCEPT` step is deliberately not copied. That exists so the deny cases cannot pass vacuously under Docker's DROP policy, and these lanes skip the network-dependent LXC tests entirely. The Azure Pipelines lane has the same gap and gets the same fix, tolerated rather than required, because a hosted pool may forbid loading modules. Where it does, the LXC tests fail exactly as they already would. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 56 out of 57 changed files in this pull request and generated no new comments.
Suppressed comments (2)
sdk/node/src/state-aware-types.ts:140
FilesystemConfigalso exposes the SDK-onlyclearPolicyOnExitfield.buildStateAwareEnvelopeforwards this object unchanged, while Rust'swire::Filesystemusesdeny_unknown_fieldsand has no such member, so a configuration accepted by this new public type fails at runtime as malformed. Narrow the filesystem type just as the network type is narrowed.
filesystem?: FilesystemConfig;
tests/scripts/run_lxc_state_aware_network_test.sh:307
- The sandbox ID is cleared even when deprovision fails, which disarms the EXIT trap and can leave a container behind. The same issue occurs for
SANDBOX_STARTED: a failed stop is recorded as stopped, so cleanup will not retry it. Retain both states until their corresponding phase succeeds.
📖 Description
Implements
StatefulSandboxBackendfor LXC, so a container can be driven through provision, start, exec, stop, and deprovision rather than only the one-shot spawn path.startserialized per container under an advisoryflock.FORWARDchain in the host netns before the container starts, and the inboundINPUTchain inside the container's own netns once it is running.ContainerPolicy::requires_firewall(), so enforcement follows the policy — a block default, a non-empty host list, or a proxy — and a start with nonetworksection denies by default.lxcto the Node SDK as a state-aware backend, with per-phase config types andLxcNetworkConfignarrowed to the three fields LXC enforces.🔍 Validation
cargo test --workspace --no-fail-fast— 2,785 passed, 2 failed. Both failures are pre-existingwslc_commontests that require an unlockedD:drive and reproduce at the base commit.npm test(Node SDK) — 283 tests, 277 passed, 0 failed, 6 skipped.cargo fmt --all -- --checkandcargo clippy --workspace --all-targets— clean.run_lxc_state_aware_network_test.shon a live LXC host — 70 passed, 0 failed, 0 quarantined.run_lxc_state_aware_test.shon a live LXC host — 8 passed, 0 failed.✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes📋 Issue Type
Microsoft Reviewers: Open in CodeFlow