[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
An LXC start config typed its filesystem block as the full FilesystemConfig, which carries clearPolicyOnExit. TypeScript accepted it and the request then failed at run time: wire::Filesystem is deny_unknown_fields, and the whole request came back as malformed_request naming the field. LXC start now takes a narrowed filesystem type carrying only the three path lists the wire accepts, matching what LxcNetworkConfig already does for removeRulesOnExit. A caller who sets the field is told at compile time. Reported in review of microsoft#849. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb32d665-8246-4d1e-b297-08366371b280
The state-aware start derived the host veth name, pinned it, and handed it to the firewall manager, but never told the signal watchdog. A fatal signal in the start window therefore ran cleanup with no interface identity. The chain and its FORWARD hooks came out, because those are found by name; the two ESTABLISHED,RELATED return rules stayed, because those carry the interface instead. They sat in FORWARD until a later stop or deprovision derived the same name and swept them, and stayed for good if the container was abandoned after the signal. Registering the veth where it is derived matches what the one-shot runner already does at lxc_runner.rs:358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb32d665-8246-4d1e-b297-08366371b280
A post-start failure halted the container two different ways and only listened to one of them. A borrowed container was stopped, and a failed stop preserved the policy so the managers would not strip a live container's rules on the way out. A container this run owned was destroyed with the result discarded, so a destroy that failed left the container up and the drop then removed its egress chain and its ingress chain -- the live-but-unfiltered state the other branch exists to prevent. Both branches now answer one question: if the halt did not take, keep the rules. Which halt was attempted stops mattering, and the asymmetry that carried the defect is gone rather than duplicated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb32d665-8246-4d1e-b297-08366371b280
The timeout path already had a louder error for a reap that failed -- it tells the caller its processes may still be running rather than reporting a plain timeout. The script ended in an unconditional success, so that branch could not be reached and a marked process left alive was announced as reaped. The exit status now comes from a scan run after the kill. It reads the marker rather than the kill's own result, because kill reports failure when the process is already gone, which is the case where the reap worked. A process torn down but not yet reaped by its parent reads an empty environ and matches nothing, so it does not count as a survivor. The scan repeats a bounded number of times to let a kill still in flight land. One existing test asserted the script ends with the literal exit 0. The behavior it names -- nothing matched reports success -- is unchanged and still covered; only the mechanism it pinned has moved, so it now asserts the exit status is the scan's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb32d665-8246-4d1e-b297-08366371b280
A review of the previous commit found the same discarded-halt shape at a site that comment had not named, and one gap in the fix itself. The proxy-host pin failure halted the container two ways and threw both results away. It runs later than the paths already corrected -- after the egress rules are applied and after the inbound chain is installed and marked -- so a failed halt there left a live container and then dropped both chains on the way out. It now goes through the same helper as every other post-start failure. That helper only ever preserved egress. The inbound manager decides on its own flag, so a container that would not die kept its egress rules and lost its inbound chain. The helper now takes the inbound manager and preserves both, and the callers that reach it before the inbound chain exists pass nothing, because a partial install must still be torn down. The reap script dropped an assignment that could never be read. Its two tests were checking the shape of the script rather than what decides its exit status: one asserted a substring that a still-broken script would satisfy, and the other no longer established the success case its name claims. Both now pin the marker match as the only thing that can report a survivor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb32d665-8246-4d1e-b297-08366371b280
The bad-release case let the container name be minted for it, so the only way to find what it left behind was to list containers before and after and destroy the difference. On a shared host that difference also holds containers another run created while this one was probing, and they were force-destroyed. This host does run concurrent suites from separate clones, so that is a real collision rather than a hypothetical one. The request now names its own container, which is what the rest of the corpus already does, and the trap removes that one name on every exit path alongside the control's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb32d665-8246-4d1e-b297-08366371b280
The merge c85ce37 rewrote .github/copilot-instructions.md with bare LF endings while resolving an unrelated conflict. .gitattributes declares `.github/copilot-instructions.md whitespace=cr-at-eol` for this path, so every one of its 470 lines then read as modified. Two costs followed. The pull request showed 936 changed lines for a file whose real content change against the merge base is 6. Merging main also conflicted across the whole file rather than across the 20 lines that actually differ. This commit changes line endings only; the content is byte-identical under --ignore-cr-at-eol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 76 out of 78 changed files in this pull request and generated 4 comments.
Suppressed comments (2)
src/core/wxc_common/src/wire.rs:590
- The rolling wire model adds state-aware LXC, but the exact development contract was not updated:
mxc_config_contract/src/dev/state_aware/provision/containment.rsdoes not recognizelxc, andprovision/mod.rshas no LXC request variant. Consequently a valid 0.9 LXC lifecycle request accepted here diverges from the exact 0.9 contract/shadow parser. Add the closed LXC provision contract and regenerate its schema/types alongside this field.
src/core/mxc_pty/src/lib.rs:416 - Abandoning this reader permanently leaks its thread and PTY descriptor when an escaped descendant keeps the secondary open. That is bounded for the CLI, but repeated timed-out calls from the in-process Rust/C#/FFI surfaces can exhaust host threads or file descriptors. Make the reader cancellable (for example via nonblocking polling plus a cancellation signal) and join it before returning instead of detaching it.
| Wslc, | ||
|
|
||
| /// <summary>Linux container managed by LXC.</summary> | ||
| Lxc, |
| One further constraint is enforced at parse time: | ||
|
|
||
| - **`enforcementMode` must be `firewall` or `both`.** Under the default | ||
| `capabilities` mode no iptables rules are installed, so the proxy env vars | ||
| would be injected while direct egress stayed open — a config that reads as | ||
| deny-all-except-proxy and enforces neither half. MXC refuses it rather than | ||
| auto-promoting the mode, so a stated enforcement level is never silently | ||
| rewritten. | ||
| - **The `url` must not carry credentials.** LXC passes the proxy URL to |
| if ! lxc-create -n "$FOREIGN" -t download -- \ | ||
| -d "$DISTRIBUTION" -r "$RELEASE" -a amd64 >/dev/null 2>&1; then | ||
| skip "could not create a container to adopt; no image cache and no network?" | ||
| fi |
| run_phase exec "$SANDBOX_ID" '"process": { "commandLine": "sleep 30", "timeout": 1000 }' >/dev/null 2>&1 | ||
| TIMEOUT_RC=$? | ||
| TIMEOUT_ELAPSED=$(( $(date +%s) - TIMEOUT_STARTED )) | ||
| if [ "$TIMEOUT_RC" -ne 0 ] && [ "$TIMEOUT_ELAPSED" -lt 25 ]; then |
Upstream added telemetry consent and administrative policy, which made `sandbox_kind` a required field on every telemetry context. The state-aware engine builds one of those contexts and now supplies the backend name there, matching what upstream passes at its own state-aware call sites. Two files conflicted. The guidance file keeps this branch's sentence naming LXC and WSLc among the state-aware backends. `wxc/src/main.rs` keeps this branch's extracted call into `mxc_engine`, which is the change the pull request exists to make. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
Four call sites and one method chain in the LXC backend were left wider than rustfmt's limit when they were written, which fails the repository's format check and takes the three lint jobs down with it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
Its header promises the script needs no LXC runtime, and it did not keep that promise. The two requests that must be admitted travel past the floor into host validation, which shells out to lxc-info. On a machine without the LXC tooling that call fails, the binary answers backend_unavailable, and the script counted the answer as a failed admission. Admission is about the version floor, and a request that reaches the backend has already cleared it. The check now accepts backend_unavailable as admission, and it looks for a refusal naming the floor before anything else so a floor regression still turns the test red. Measured on a PATH with every lxc* command hidden: 6 passed and 1 failed before, 7 passed and 0 failed after, and still 7 passed and 0 failed on a host that does have LXC. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 76 out of 78 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- The C# surface now advertises
Lxc, but it cannot build a valid LXC provision request.ValidateProvisionOptionsaccepts onlynullfor this enum value, andBuildProvisionEnvelopehas no LXC arm to emit the requiredexperimental.lxc.provision.distributionandrelease, so every C# provision attempt reaches native code malformed. Add LXC-specific provision/start option types and serialize their required image and start policies before exposing this enum value.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
src/core/wxc_common/src/wire.rs:590
- Only the rolling wire model is extended here. The supported
0.9.0-alphaexact state-aware contract still has no LXC containment/provision variant, and the dev adapter only addslxc: Nonefillers. Consequently a version that is documented as being above the 0.8 floor cannot round-trip through the exact contract. Add the LXC provision contract and adapter arm, then regenerate the exact 0.9 schema/types alongside the rolling artifacts.
tests/scripts/run_lxc_state_aware_test.sh:172 - This condition accepts any fast nonzero failure as proof of a timeout—for example, an attach error or missing command passes in under 25 seconds. Capture the output and require the timeout diagnostic so the test cannot go green without reaching the timeout path.
docs/lxc-support/lxc-backend.md:349 - The removed constraint still applies to pre-0.8 one-shot requests:
installs_firewallconsultsenforcementModefor legacy schemas, andLxcRunnerstill rejects a proxy under capabilities mode. As written, this section tells users that credentials are the only constraint even though a legacy proxy without firewall/both still fails at runtime.
One further constraint is enforced at parse time:
| # Naming the container means the failed create can be cleaned up by name. Left | ||
| # to mint its own, the only way to find it afterwards is to diff `lxc-ls`, which | ||
| # on a shared host also catches containers other runs created. | ||
| BAD_RELEASE_CONTAINER="CLI-LXC-Experimental-Bad-Release" |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 76 out of 78 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs:57
- Hard-coding
lxc: Noneleaves the exact0.9.0-alphastate-aware contract without the LXC provision shape, even though this PR documents support for schema 0.8.0 or later. The rolling parser can acceptexperimental.lxc.provision, but the per-version parser cannot converge and will reject/drop it when exact dispatch becomes authoritative. Per the versioned-contract pattern used in this directory, add the closed LXC provision request/experimental types and adapter arm, then regenerate the exact schema and TypeScript oracle.
| StateAwareContainment.IsolationSession => IsolationSessionContainment, | ||
| StateAwareContainment.WindowsSandbox => WindowsSandboxContainment, | ||
| StateAwareContainment.Wslc => WslcContainment, | ||
| StateAwareContainment.Lxc => LxcContainment, |
| let exit_code = outcome | ||
| .map(|(exit_code, _, _)| exit_code) | ||
| .map_err(|e| MxcError::backend_error(format!("Execution failed: {e}")))?; |
The control that proves a failed container create surfaces as backend_error named its container "CLI-LXC-Experimental-Bad-Release", which is 32 characters against a 20-character bound. The request was refused as a malformed containerId before it ever reached lxc-create, and the control reported that refusal as a failure. The bound is right and the name was wrong. A shorter name lets the request through to the create it is supposed to fail inside. Measured on a host with LXC installed: 5 passed and 1 failed before, 6 passed and 0 failed after, with the error now carrying the lxc-create output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 76 out of 78 changed files in this pull request and generated no new comments.
Suppressed comments (5)
Previously missed (2) — in code that hasn't changed since the last review.
sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs:395
- Routing LXC through the managed lifecycle also exposes
ExecInSandboxandExecInSandboxAsync, but both call the native streamingmxc_state_aware_execentry point. The engine explicitly rejects LXC there because LXC implements lifecycle exec only, notSandboxProcessstreaming, so these primary .NET exec APIs always returnunsupported_phasefor an LXC ID; onlyExecInSandboxAttachedcan work. Either implement LXC streaming exec or expose an LXC-compatible run-to-completion path and reject unsupported methods before advertising full managed lifecycle support.
StateAwareContainment.Lxc => LxcContainment,
src/backends/lxc/common/src/state_aware.rs:914
- A failed
lxc-createcan leave a partially defined container, but this path returns without cleanup and without returning the minted container name. For auto-generated names the caller then has no identifier with which to deprovision the leaked resource. Track creation ownership and perform safe best-effort cleanup on failure; at minimum include the container name in the error so an operator can recover it.
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding
Lxcto the managed registry exposesProvisionSandbox(StateAwareContainment.Lxc, ...), but the managed API cannot build a valid LXC provision request. There is noLxcProvisionOptions;nulloptions emit no requiredexperimental.lxc.provisionobject, whileValidateProvisionOptionsrejects every existing non-null option type for LXC. Consequently every .NET LXC provision attempt fails before or during native validation. Add a typed LXC provision options class (including distribution/release and optional containerId), serialize it inBuildProvisionEnvelope, and accept it in validation.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
src/core/wxc_common/src/wire.rs:590
- The new rolling wire field is missing from the exact
0.9.0-alphacontract.mxc_config_contract::dev::state_aware::ContainmentandProvisionRequesthave no LXC variant,parse_provisionhas no LXC branch, and the dev adapter hardcodeslxc: None; therefore the exact schema/types reject an LXC lifecycle request that the rolling model accepts. Add the LXC provision contract and adapter, then regenerate the exact0.9.0-alphaschema and TypeScript oracle as well as the rolling artifacts.
src/backends/lxc/common/src/state_aware.rs:1043 - This does not provide enforceable ingress isolation against the sandboxed workload. The chain is installed inside the container's own network namespace, while LXC exec runs as container root with
CAP_NET_ADMIN, so contained code can flush or delete the INPUT chain and reopen inbound access immediately. Do not reportingress.default: denyas enforced unless the filter is moved to host-owned state or the workload is prevented from modifying netfilter (for example by dropping the relevant capability).
The test reported every assertion passing and still exited 1, which the suite recorded as a failed test. set -e stays in force inside an EXIT trap, and the trap destroys the container the bad-release request was supposed to fail to create. Destroying a container that was never created returns 1, which aborted the cleanup function before the return 0 that was meant to absorb it and handed that status to the whole script. The two destroys are now allowed to fail. Measured on a host with LXC installed: 6 passed, 0 failed, exit 1 before, and 6 passed, 0 failed, exit 0 after. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 76 out of 78 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
tests/scripts/run_lxc_experimental_provision_fields_test.sh:205
- This assertion contradicts the script's stated ability to run without an LXC runtime. On such a host the well-formed request stops at the availability check with
backend_unavailable, notbackend_error, so the test fails even though field validation behaved correctly. Either require/skip when LXC is absent if backend-error mapping is part of this test, or acceptbackend_unavailablehere while still rejecting the field-validation diagnostics.
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding this enum value exposes
ProvisionSandbox(StateAwareContainment.Lxc), but the managed API has no LXC provision options andBuildProvisionEnvelopehas no LXC case. Consequently it can never serialize the requiredexperimental.lxc.provision.distributionandrelease, so every .NET LXC provision call is rejected. Add the LXC-specific provision/start option types and envelope serialization before advertising this backend, or leave LXC out of the enum.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
| /// The lowest schema version LXC's state-aware lifecycle accepts. | ||
| pub const STATE_AWARE_MIN_SCHEMA_VERSION: &str = "0.8.0"; |
| // LXC's network surface is the directional field set, which a pre-0.8 schema | ||
| // does not enforce. On the shared default a policy would be accepted and no | ||
| // firewall installed. | ||
| export const LXC_STATE_AWARE_VERSION = '0.8.0-alpha'; |
A configuration file that names something the backend cannot use produced a running container and a log line. Two entries behaved that way. An egress destination that does not parse -- 10.0.0.0/33, 10.0.0.0/+24, 10.0.0.0/20/8, /24, a blank string -- resolved to nothing, warned, and the remaining entries were programmed. The container then ran under a policy the author never wrote, and the one entry they got wrong was the one silently missing. In proxy mode the allow list is never resolved at all, so the warning could not fire there even in principle. An environment entry that is not KEY=VALUE was dropped where it was translated into lxc-attach arguments, and the script ran with an environment differing from the configured one in no observable way. Both are now refused ahead of container creation and firewall programming, so a rejected request leaves nothing to roll back. The cut is malformed against unresolved, not warning against error. A typo matches nothing on any host at any moment and is decidable without touching the network. A well-formed hostname whose lookup failed is a fact about this moment's DNS, and it keeps the existing warning: erroring there would make sandbox startup depend on live DNS, and an allow that goes unwritten can never widen what the container reaches. The two existing hard errors for an unresolvable deny entry are untouched. Schema 0.8 is unaffected. Its peers arrive as an already-parsed NetworkCidr, so a malformed destination cannot reach the backend. The legacy allowedHosts and blockedHosts lists are raw strings that nothing validates before this point. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
A configuration could name a network posture LXC never applied, and the run
reported success. The caller was told their policy was in effect while the
container held whatever network the host gave it. Four cases behaved this
way, each warning to the log and continuing.
LXC now refuses the run and names the setting:
- An allow entry that resolves to no address. No rule can carry it, and
under a blocking default the destination the configuration names stays
unreachable.
- An allow list supplied beside a proxy. Proxy mode permits the proxy
endpoint and nothing else; the listed destinations were never programmed.
- An enforcement mode of 'capabilities' on a configuration that states a
network posture. This backend filters with iptables and has no capability
mechanism for the network, and installed no rule in either direction.
- A host whose IPv6 stack is loaded but not yet addressed. This was read as
a kernel without IPv6 and produced IPv4-only rules. An address arriving
during the run left IPv6 egress unfiltered. It is now treated as unknown,
which the surrounding code already fails closed on.
The refusals stay narrow. A configuration that states no network posture is
not asking for enforcement and still runs, which is what keeps configurations
written before the enforcement mode existed working.
This changes behavior for existing 0.7 callers. A configuration carrying
'defaultPolicy' or a host list without an explicit enforcement mode used to
run with an open network and now fails. That is the defect, not a
regression: those callers never had the policy they asked for.
Two E2E tests asserted the old silence and were rewritten. A new refusal
suite covers the remaining cases with a control proving a posture-free
configuration still runs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
Six passages described behavior this branch replaced. A reader following them would write a configuration expecting a bad entry to be dropped and the run to carry on, and would instead get a refusal. The network contract states that a backend rejects configurations it cannot enforce, outside the exceptions a backend's own document records. The three fail-open passages here were exactly such exceptions. Removing them is what returns this backend to the contract's default rather than merely tidying prose. Corrected: - a malformed process.env entry fails the run instead of being dropped - an allowedHosts entry that resolves to nothing fails firewall setup - a destination that could never match anything is refused before any chain is created, separately from a well-formed name that resolves to nothing today - host IPv6 that is loaded but not yet addressed reads as unknown, not as off - the capabilities refusal covers any stated network posture, not only a proxy - egress and inbound both key on the existence of if_inet6, so the asymmetry this document called deliberate and one-directional no longer exists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
lxc_runner.rs is stored LF on this branch and CRLF on main. An editor running on Windows rewrites it with CRLF on any edit, which re-commits all 1,890 lines as a change and buries the real diff inside a whole-file rewrite. That is what happened when this branch's own history normalized the file. The rule pins the stored ending for this backend's sources. Adding it renormalizes nothing: no .rs file under the LXC backend is stored CRLF today, so the only file in this commit is .gitattributes itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
There was a problem hiding this comment.
🟡 Changes recommended
The .NET API is incomplete, exact-version contracts diverge, and LXC exec timeout isolation remains bypassable.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding
Lxchere exposes it as a usable .NET lifecycle backend, but the .NET surface cannot build a valid LXC request. There is noLxcProvisionOptions,BuildProvisionEnvelopehas no LXC arm to emit the requiredexperimental.lxc.provision.{distribution,release}, andValidateProvisionOptionsacceptsnull; consequentlyProvisionSandbox(StateAwareContainment.Lxc)always reaches Rust without the required fields and fails asmalformed_request. The start API also only acceptsStateAwarePhaseOptions, so .NET callers cannot supply the filesystem/network policy that LXC applies at start. Add backend-specific provision/start option types and envelope serialization (plus typed metadata) before registering this enum value.
Lxc,
sdk/node/src/state-aware.ts:169
- This channel assumption is already false for the new LXC path.
LxcStateAwareRunner::execrelays workload output to stdout before returning, and a late failure such as timeout then appends the error envelope to that same stream; the added test atstate-aware.test.ts:464-489confirmstryParseErrorEnvelope(stdout)fails and the SDK resolves anExecResultinstead of throwing the documented typedMxcError. Please use a framing/separate-channel mechanism for dispatch failures (or otherwise make late failures unambiguous) rather than documenting stdout as envelope-only.
// The cross-backend contract (§7.3) reserves stdout for the response
// envelope in every phase, so a dispatch failure is always found there and
// no backend needs its own channel rule.
src/core/wxc_common/src/wire.rs:590
- The new rolling wire field is not represented in the per-version
0.9.0-alphacontract.mxc_config_contract/src/dev/state_aware/provision/mod.rsstill has only IsolationSession/WindowsSandbox/WSLC provision variants, and the dev adapter explicitly setslxc: None; the exact schema and generated exact SDK oracle therefore also omit state-aware LXC. This leaves rolling and exact parsing inconsistent and will reject LXC when exact dispatch becomes authoritative. Add the closed LXC provision contract, adapter, exact-contract tests, and regenerate the0.9.0-alphaschema/types alongside this wire change.
- Files reviewed: 89/91 changed files
- Comments generated: 1
- Review effort level: Balanced
| let marker = mint_exec_marker(); | ||
| signal_cleanup::set_active_exec(container_name, &marker); | ||
| // Cleared unconditionally: an empty env would otherwise leave | ||
| // `lxc-attach` in keep-env mode, inheriting this process's environment | ||
| // and the credentials in it. | ||
| let outcome = container.attach_run( | ||
| &request.script_code, | ||
| &request.working_directory, | ||
| &request.env, | ||
| true, | ||
| timeout, | ||
| Some(&marker), |
A dry run answers "would this configuration be accepted?", and a caller acts on that answer without ever starting a container. A configuration naming a proxy under 'capabilities' enforcement got two different answers: the dry run reported success, and the real run refused it. The person reading the dry run was told their proxy configuration was fine. The refusal lived in the execution path, which a dry run skips by design. It now lives in the validation hook, which runs before the dry-run answer is produced. Both paths give the same refusal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
There was a problem hiding this comment.
🟡 Changes recommended
Network enforcement remains bypassable, while managed SDK and schema contracts are incomplete.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (6)
Previously missed (1) — in code that hasn't changed since the last review.
sdk/node/src/state-aware-types.ts:129
- This statement conflicts with the new lifecycle-wide schema floor: an explicit pre-0.8 LXC version is rejected before the start policy is evaluated, so it does not merely record these fields and skip the firewall. Document the rejection instead.
src/backends/lxc/common/src/network_ingress.rs:509
- This rule only blocks the bridge gateway address, but
hostLoopback: denycovers every container-to-host path. A container can address another IP assigned to the host (for example its LAN address); Linux locally delivers that packet to the host even though the destination is not the bridge gateway, so this OUTPUT rule does not match it. Enforce the deny in the host namespace using the veth plus a local-destination match (or enumerate all host addresses), otherwise the default 0.8 posture still exposes host services.
src/backends/lxc/common/src/state_aware.rs:1043 - The ingress policy is not an enforcement boundary against the sandboxed workload. LXC exec runs as container root with
CAP_NET_ADMIN, so contained code can flush the INPUT/OUTPUT rules installed in its own network namespace and restore inbound/host reachability. Move these controls to host-owned rules scoped to the host-side veth, or removeCAP_NET_ADMINbefore advertising ingress enforcement.
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24 - This public enum value cannot actually provision an LXC sandbox.
BuildProvisionEnvelopehas no LXC options arm,ValidateProvisionOptionsaccepts(Lxc, null), and the resulting request omits the requiredexperimental.lxc.provision.distribution/release, so Rust always returnsmalformed_request; there is also no start-options type for LXC filesystem/network policy. Add the LXC-specific option types and serialization, or do not expose/accept this backend in the managed lifecycle API.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
src/core/wxc_common/src/wire.rs:590
- LXC was added only to the rolling wire model. The closed
0.9.0-alphacontract still omits LXC from its state-aware containment/provision unions, and the dev adapter hard-codeslxc: None; consequently the generated exact schema/types reject an LXC lifecycle request even though the backend accepts 0.9. Per the repository's dual-contract workflow, add the matching exact contract and adapter support and regenerate the exact artifacts.
sdk/node/src/state-aware-helper.ts:23 - This introduces a backend-specific schema default outside the canonical version source.
schemas/schema-version.jsonhas no LXC state-aware entry, andcheck-schema-versions.jsvalidates only the shared and WSLC defaults, so the new TypeScript and C# constants can drift independently. Add a canonicalstateAwareLxcvalue and extend the drift gate to check both SDKs.
// LXC's network surface is the directional field set, which a pre-0.8 schema
// does not enforce. On the shared default a policy would be accepted and no
// firewall installed.
export const LXC_STATE_AWARE_VERSION = '0.8.0-alpha';
- Files reviewed: 89/91 changed files
- Comments generated: 2
- Review effort level: Balanced
| it('returns an ExecResult when a script is killed by its timeout after streaming output', async () => { | ||
| // Characterization, not an endorsement. §7.3 models exec as "either the | ||
| // script's output (success) or exactly one envelope (failure)", but a | ||
| // timeout is a dispatch failure raised *after* the script has streamed, so | ||
| // stdout holds both and whole-string parsing matches neither. The caller |
| if policy.network_mode_specified { | ||
| return Err( | ||
| "network.enforcementMode='capabilities' names a posture LXC cannot \ | ||
| enforce. This backend filters with iptables and has no capability \ | ||
| mechanism for the network, so under 'capabilities' no rule is \ |
LXC's state-aware lifecycle is not stable yet, and a caller could reach it without opting in. It now requires --experimental like the other experimental backends, and reads as experimental in the docs. The wire contract declares LXC's state-aware provision shape, so the published schema and both SDKs can describe an LXC config. The Node SDK stamps 0.8 on an unversioned LXC request, the lowest version LXC accepts, and carries a caller-supplied container name through instead of generating one. LXC's rule about proxying without a firewall now lives in the LXC backend rather than the shared parser, and covers a stated capabilities mode as well. LXC has no capability mechanism for the network, so a legacy config naming that mode is refused rather than run with nothing enforced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3c1dc0e-d369-4375-b472-5e7b832ee8a9
There was a problem hiding this comment.
🔵 Needs a closer look
LXC is incomplete in the exact contract and .NET SDK, while timeout handling and legacy mount migration can produce incorrect or unsafe behavior.
Review details
Suppressed comments (4)
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding
Lxcto the public state-aware enum exposes a backend that the managed SDK cannot actually provision: there is noLxcProvisionOptions,BuildProvisionEnvelopehas no LXC arm, andValidateProvisionOptionsacceptsnullfor LXC. The resulting envelope omits the requiredexperimental.lxc.provision.distributionandrelease, so everyProvisionSandbox(StateAwareContainment.Lxc)call is rejected. The start API also cannot carry LXC's filesystem/network policy. Please add the LXC-specific provision/start option types and serialization, or do not expose this enum value yet.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
src/core/wxc_common/src/wire.rs:590
- The rolling wire model now accepts
experimental.lxc, but the exact 0.9 contract still has only IsolationSession, Windows Sandbox, and WSLC provision variants, and this PR's exact adapter explicitly setslxc: None. Thus a valid 0.9 LXC lifecycle request is accepted by today's parser but rejected by the future-authoritative exact parser. Updatemxc_config_contract::dev, its adapter, and regenerated exact schema/TypeScript oracle together as required by the parser transition.
src/backends/lxc/common/src/state_aware.rs:1146 - A timeout is converted here into
backend_errorafterattach_runhas already streamed workload output.lxc-execthen appends an error envelope to that same stdout, soexecInSandboxAsynccannot parse the envelope and returns an ordinary nonzeroExecResult; the new SDK test explicitly characterizes this gap. This also contradicts the lifecycle contract, which says runtime timeouts surface as sentinel exec exit codes rather than typed wire errors. Preserve the timed-out process's sentinel exit status on the executor path instead of returningMxcErrorafter output has begun.
src/backends/lxc/common/src/filesystem_mounts.rs:215 - The replacement only recognizes mount entries carrying the new marker, but MXC versions before this change appended their own
lxc.mount.entrylines without a marker. A preserved/adopted container first configured by an older binary therefore keeps those legacy MXC mounts forever; restarting it with a narrower policy still exposes the old host paths. Please provide a migration/ownership mechanism for pre-marker MXC entries rather than treating every unmarked entry as operator-owned.
// Derive the whole mount set first, then commit it in one config rewrite.
// liblxc accumulates `lxc.mount.entry` lines across restarts, so the
// previous run's MXC mounts have to go; committing the replacement one
// entry at a time meant a crash or a rejected path partway through left a
// durable config matching no policy anyone wrote. Only MXC's own entries
// are replaced -- baseline mounts the distribution template or the operator
// placed in the config carry no marker and survive.
- Files reviewed: 93/95 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The shared state-aware surface now describes an exec by how its stdio is carried rather than by who is calling, and LXC still referred to the caller. LXC refuses the same request as before, on the same terms, and the Linux build compiles again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3c1dc0e-d369-4375-b472-5e7b832ee8a9
There was a problem hiding this comment.
🟡 Changes recommended
Contract-version, .NET provisioning, timeout signaling, compatibility, and failed-provision cleanup issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 93/95 changed files
- Comments generated: 2
- Review effort level: Balanced
| if created { | ||
| container | ||
| .create(&config.distribution, &config.release) | ||
| .map_err(|e| MxcError::backend_error(format!("Failed to create container: {e}")))?; |
| //! this logic lived in `wxc`'s `main.rs`, the `lxc` executor called | ||
| //! [`crate::run_state_aware`] directly and so silently produced no lifecycle | ||
| //! telemetry and no correlation vector: a Linux lifecycle was invisible to the | ||
| //! same dashboards that observed a Windows one, and a client relaying a | ||
| //! provision-seeded cV had nothing to relay. Sharing the orchestration is what | ||
| //! keeps the two executors from drifting apart again. |
There was a problem hiding this comment.
🔵 Needs a closer look
Legacy mounts can retain removed filesystem access, and the exact-contract and C# integrations are incomplete.
Review details
Suppressed comments (4)
src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs:57
- The new LXC state-aware shape exists only in the rolling wire model; this adapter still hard-codes
lxc: None, and the closed dev contract has no LXC provision variant. Consequently the generated exact 0.9 schema rejects a request that the current parser accepts, so switching exact dispatch to authority would break this feature. Add LXC tomxc_config_contract::devand this adapter, then regenerate both exact artifacts as required bydocs/versioning.md:204-214.
sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs:395 - This makes LXC publicly routable, but
ValidateProvisionOptionsaccepts onlynullfor LXC andBuildProvisionEnvelopehas no LXC options arm. The resulting envelope therefore omits requiredexperimental.lxc.provision.distributionandrelease, so every C# LXC provision call fails asmalformed_request, with no API capable of supplying the fields. Add an LXC provision-options type, validate it, serialize its fields, and cover the envelope in the C# tests before exposing this enum case.
StateAwareContainment.Lxc => LxcContainment,
sdk/node/src/state-aware-helper.ts:21
- This new backend-specific schema default is not represented in
schemas/schema-version.json, andscripts/versioning/check-schema-versions.jsvalidates only the shared and WSLC defaults. The new Node and C# LXC constants can therefore drift silently, contrary to the repository's canonical-version rule. Add a canonical LXC state-aware field and extend the sync gate (or derive this from an existing canonical field).
// LXC refuses any state-aware request below 0.8.
export const LXC_STATE_AWARE_VERSION = '0.8.0-alpha';
src/backends/lxc/common/src/filesystem_mounts.rs:317
- This replacement only removes entries preceded by the new
# mxc-managed-mountmarker. Containers preserved by earlier releases have MXC mounts appended without that marker (the removed code calledset_config_itemdirectly), so adopting/restarting one with a tighter policy leaves the old broad mount active alongside the new set. That can preserve filesystem access the caller explicitly removed. Add a safe legacy migration/refusal strategy before treating the marked rewrite as authoritative.
container.replace_mxc_mount_entries(&entries)
- Files reviewed: 93/95 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Problem
LXC only runs one-shot. A caller hands it a config, it builds a container, runs a single workload, and destroys the container. Driving several commands against the same environment costs a full container build and teardown every time, and nothing the first command leaves behind is there for the second.
Fix
LXC implements the state-aware lifecycle: provision, start, exec, stop, and deprovision. A caller builds a container once, execs against it as many times as needed, and tears it down when finished.
--experimentalthe request is refused and the message names the flag. One-shot LXC is untouched and still needs no flag.enforcementModeasked for it, which leaves a config written before that field behaving as it always did.lxcas a state-aware backend with per-phase config types, and sends0.8.0-alpha.🔍 Validation
Measured on
ec0ba3a3:run_lxc_all_tests.shagainst a live LXC host — 31 passed, 0 failed, 0 skipped, 1 disabled.cargo test -p lxc_common -p bwrap_commonon Linux — 829 passed, 0 failed.cargo test -p wxc_common -p mxc_engineon Windows — 1,150 passed, 0 failed.npm testinsdk/node— 317 passed, 0 failed;tsc --noEmitclean.wire.tsregenerate byte-identical to what is committed.✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes📋 Issue Type
Microsoft Reviewers: Open in CodeFlow