Skip to content

[LXC] State-aware sandbox lifecycle - #849

Open
Darren Hoehna (dhoehna) wants to merge 155 commits into
microsoft:mainfrom
dhoehna:user/dahoehna/lxc-lifecycle-current
Open

[LXC] State-aware sandbox lifecycle#849
Darren Hoehna (dhoehna) wants to merge 155 commits into
microsoft:mainfrom
dhoehna:user/dahoehna/lxc-lifecycle-current

Conversation

@dhoehna

@dhoehna Darren Hoehna (dhoehna) commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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.

  • The state-aware surface requires the experimental opt-in, joining the three backends already behind it. Without --experimental the request is refused and the message names the flag. One-shot LXC is untouched and still needs no flag.
  • Two callers racing to start the same container can no longer both bring it up. Starts are serialized per container.
  • Where the request asks for a firewall, policy is enforced on traffic in both directions rather than outbound only.
  • Enforcement follows the schema the request declares. A 0.8 directional config always enforces. A 0.7 config enforces only where enforcementMode asked for it, which leaves a config written before that field behaving as it always did.
  • The Node SDK exposes lxc as a state-aware backend with per-phase config types, and sends 0.8.0-alpha.

🔍 Validation

Measured on ec0ba3a3:

  • run_lxc_all_tests.sh against a live LXC host — 31 passed, 0 failed, 0 skipped, 1 disabled.
  • cargo test -p lxc_common -p bwrap_common on Linux — 829 passed, 0 failed.
  • cargo test -p wxc_common -p mxc_engine on Windows — 1,150 passed, 0 failed.
  • npm test in sdk/node — 317 passed, 0 failed; tsc --noEmit clean.
  • The generated schema and wire.ts regenerate byte-identical to what is committed.

✅ Checklist

  • Signed the Contributor License Agreement
  • Linked to an issue
  • Updated documentation (if applicable)
  • Updated Copilot instructions (if build, architecture, or conventions changed)
  • If this PR changes Cargo.lock, the dependency-feed-check check passes

📋 Issue Type

  • Bug fix
  • Feature
  • Task
Microsoft Reviewers: Open in CodeFlow

Darren Hoehna (dhoehna) and others added 30 commits July 13, 2026 09:44
…) (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
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.rs does not recognize lxc, and provision/mod.rs has 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,
Comment thread docs/lxc-support/lxc-backend.md Outdated
Comment on lines 348 to 350
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
Comment on lines +178 to +181
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
Comment on lines +165 to +168
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. ValidateProvisionOptions accepts only null for this enum value, and BuildProvisionEnvelope has no LXC arm to emit the required experimental.lxc.provision.distribution and release, 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-alpha exact state-aware contract still has no LXC containment/provision variant, and the dev adapter only adds lxc: None fillers. 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_firewall consults enforcementMode for legacy schemas, and LxcRunner still 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"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: None leaves the exact 0.9.0-alpha state-aware contract without the LXC provision shape, even though this PR documents support for schema 0.8.0 or later. The rolling parser can accept experimental.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,
Comment on lines +1144 to +1146
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ExecInSandbox and ExecInSandboxAsync, but both call the native streaming mxc_state_aware_exec entry point. The engine explicitly rejects LXC there because LXC implements lifecycle exec only, not SandboxProcess streaming, so these primary .NET exec APIs always return unsupported_phase for an LXC ID; only ExecInSandboxAttached can 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-create can 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 Lxc to the managed registry exposes ProvisionSandbox(StateAwareContainment.Lxc, ...), but the managed API cannot build a valid LXC provision request. There is no LxcProvisionOptions; null options emit no required experimental.lxc.provision object, while ValidateProvisionOptions rejects 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 in BuildProvisionEnvelope, 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-alpha contract. mxc_config_contract::dev::state_aware::Containment and ProvisionRequest have no LXC variant, parse_provision has no LXC branch, and the dev adapter hardcodes lxc: 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 exact 0.9.0-alpha schema 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 report ingress.default: deny as 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, not backend_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 accept backend_unavailable here 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 and BuildProvisionEnvelope has no LXC case. Consequently it can never serialize the required experimental.lxc.provision.distribution and release, 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,

Comment on lines +328 to +329
/// The lowest schema version LXC's state-aware lifecycle accepts.
pub const STATE_AWARE_MIN_SCHEMA_VERSION: &str = "0.8.0";
Comment thread sdk/node/src/state-aware-helper.ts Outdated
Comment on lines +20 to +23
// 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 Lxc here exposes it as a usable .NET lifecycle backend, but the .NET surface cannot build a valid LXC request. There is no LxcProvisionOptions, BuildProvisionEnvelope has no LXC arm to emit the required experimental.lxc.provision.{distribution,release}, and ValidateProvisionOptions accepts null; consequently ProvisionSandbox(StateAwareContainment.Lxc) always reaches Rust without the required fields and fails as malformed_request. The start API also only accepts StateAwarePhaseOptions, 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::exec relays 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 at state-aware.test.ts:464-489 confirms tryParseErrorEnvelope(stdout) fails and the SDK resolves an ExecResult instead of throwing the documented typed MxcError. 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-alpha contract. mxc_config_contract/src/dev/state_aware/provision/mod.rs still has only IsolationSession/WindowsSandbox/WSLC provision variants, and the dev adapter explicitly sets lxc: 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 the 0.9.0-alpha schema/types alongside this wire change.
  • Files reviewed: 89/91 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +1129 to +1140
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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: deny covers 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 remove CAP_NET_ADMIN before advertising ingress enforcement.
    sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
  • This public enum value cannot actually provision an LXC sandbox. BuildProvisionEnvelope has no LXC options arm, ValidateProvisionOptions accepts (Lxc, null), and the resulting request omits the required experimental.lxc.provision.distribution/release, so Rust always returns malformed_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-alpha contract still omits LXC from its state-aware containment/provision unions, and the dev adapter hard-codes lxc: 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.json has no LXC state-aware entry, and check-schema-versions.js validates only the shared and WSLC defaults, so the new TypeScript and C# constants can drift independently. Add a canonical stateAwareLxc value 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

Comment on lines +464 to +468
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
Comment on lines +2540 to +2544
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 Lxc to the public state-aware enum exposes a backend that the managed SDK cannot actually provision: there is no LxcProvisionOptions, BuildProvisionEnvelope has no LXC arm, and ValidateProvisionOptions accepts null for LXC. The resulting envelope omits the required experimental.lxc.provision.distribution and release, so every ProvisionSandbox(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 sets lxc: None. Thus a valid 0.9 LXC lifecycle request is accepted by today's parser but rejected by the future-authoritative exact parser. Update mxc_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_error after attach_run has already streamed workload output. lxc-exec then appends an error envelope to that same stdout, so execInSandboxAsync cannot parse the envelope and returns an ordinary nonzero ExecResult; 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 returning MxcError after 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.entry lines 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment on lines +911 to +914
if created {
container
.create(&config.distribution, &config.release)
.map_err(|e| MxcError::backend_error(format!("Failed to create container: {e}")))?;
Comment on lines +14 to +19
//! 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 to mxc_config_contract::dev and this adapter, then regenerate both exact artifacts as required by docs/versioning.md:204-214.
    sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs:395
  • This makes LXC publicly routable, but ValidateProvisionOptions accepts only null for LXC and BuildProvisionEnvelope has no LXC options arm. The resulting envelope therefore omits required experimental.lxc.provision.distribution and release, so every C# LXC provision call fails as malformed_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, and scripts/versioning/check-schema-versions.js validates 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-mount marker. Containers preserved by earlier releases have MXC mounts appended without that marker (the removed code called set_config_item directly), 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Copilot-Instructions PR modifies Copilot instruction files (.github/copilot-instructions.md or .github/instructions/) Needs-Attention Issue needs attention from Microsoft

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants