Skip to content

[LXC] State-aware sandbox lifecycle - #849

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

Darren Hoehna (dhoehna) wants to merge 161 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

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 thread sdk/node/tests/unit/state-aware.test.ts
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}")))?;

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.

This seems to be a legit issue

Comment thread src/core/mxc_engine/src/state_aware_telemetry.rs Outdated

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

_ => StateAwareVersion,
};

private static void ValidateProvisionOptions(

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.

This validator has no LXC arm, and no LxcProvisionOptions type exists, so every typed options object falls through to _ => false?

if !uses_directional_schema {
return false;
}
Self::stated_ingress(policy, uses_directional_schema)

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.

is_some_and returns false when there's no ingress section, so this returns false? This is opposite of the comment above: "An absent 0.8 ingress section still denies."

let veth = enforced_veth_name(container_name);
let stopped = container.kill();
signal_cleanup::clear_active();
if let Err(stop_err) = stopped {

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.

Can the container exit on its own between start and ingress install? If so, kill() will fail because it's already gone, and this branch reads every failure as "still up", returning early and leaving the egress chain installed, which then blocks every later start of that name.

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}")))?;

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.

This seems to be a legit issue

loop {
// sigwait isn't normally interruptible; on the unlikely failure, retry.
let Ok(sig) = mask.wait() else { continue };
let active = std::mem::take(&mut *lock_slot());

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.

stop takes LifecycleLock for exactly this race (state_aware.rs:1168-1172); the watchdog doesn't. A signal between :982 and :995 removes the firewall, the main thread starts the container anyway, and exit leaves it running unfiltered. NetworkOnly stops rather than destroys, so nothing reclaims it, the base's destroy() did.

Shall we quiesce the transition first?

}
});
}
std::process::exit(128 + sig as i32);

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.

Exiting here drops the process-held flock immediately, but the children this process spawned outlive it?

// that wait is a running container with no inbound filtering.
let veth = enforced_veth_name(container_name);
let stopped = container.kill();
signal_cleanup::clear_active();

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.

This runs before cleanup_network_owned, so a signal arriving during that iptables sequence finds slot.name == None, skips rollback entirely, and strands whatever chain or FORWARD hook hadn't been removed yet with no owner left to reclaim it?

main and this branch had each grown their own answer to the same question:
how a lifecycle phase gets its correlation vector.  This branch had the
caller supply one on the wire.  main derives it internally from the sandbox
id and rejects `correlationVector` as an input, and already carries parser
tests refusing the field.  main's design wins here -- a caller-supplied
vector is a caller-controlled field on a shared contract.

Folding that in left `run_state_aware_with_telemetry` still written against
the relay design.  The shared helper is rewritten onto main's: it derives
the phase vector, anchors the effective policy hash, publishes the driver's
diagnostic sinks to the dispatch thread, and records the sandbox identity.
Both executors now run that one body.  Previously only `wxc` did this work,
and a Linux lifecycle emitted no telemetry and no correlation vector at all.
`lxc` now initializes telemetry for the state-aware path and gets all of it.

Telemetry is stable rather than experimental now.  The helper takes
`telemetry_active` in place of `experimental`.

Config-rejection reporting moves out of `wxc`'s `main.rs` into
`wxc_common::config_rejection`, where the shared helper can report a refusal
without a second copy of the reason mapping.  `lxc` covers main's new
`ParseError::OneShotMalformed` variant on its existing parse-error arm.  It
does no rejection logging on any other parse arm, and this one does none
either.

Verified: the workspace compiles clean, 1337 Rust tests pass, the generated
schema and TypeScript regenerate with no drift, and 313 node SDK unit tests
pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a671670c-ad14-4302-8077-56bca655b58b
main landed verbose Learning Mode diagnostics while this branch was being
merged, and it declares a new module and a new re-export in the two files
this branch had just rewritten.  Both conflicts are additions on either
side with nothing in common, so both sides are kept: `mxc_engine` exposes
the state-aware telemetry helper and the verbose-telemetry emitter side by
side, and `wxc`'s one-shot path emits the verbose artifact before the
completion event.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a671670c-ad14-4302-8077-56bca655b58b

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 C# lifecycle surface cannot provision LXC, legacy mounts can survive narrowed policies, and SDK/network and rejection-reporting gaps remain.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:23

  • Exposing Lxc here makes it selectable through the C# lifecycle API, but the managed surface cannot construct a valid LXC provision request: there is no LXC provision-options type, BuildProvisionEnvelope never emits experimental.lxc.provision, and the native parser requires distribution and release. As a result, every C# LXC provision call fails before a container can be created. Add typed LXC provision options and serialize that block before advertising this backend.
    /// <summary>Linux container managed by LXC.</summary>

src/backends/lxc/common/src/filesystem_mounts.rs:317

  • This replacement only removes entries carrying the new marker. Containers preserved by earlier MXC versions have the same MXC-generated lxc.mount.entry lines without that marker, so adopting/reusing one after upgrade leaves the old grants active alongside the new policy. Narrowing or removing a mount can therefore still expose the previously granted host path. Please add a migration/ownership strategy for legacy MXC entries (or reject such reuse) before treating the new policy as authoritative.
    container.replace_mxc_mount_entries(&entries)
  • Files reviewed: 95/97 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment on lines +115 to +123
export interface LxcNetworkConfig {
defaultPolicy?: NetworkConfig['defaultPolicy'];
allowedHosts?: NetworkConfig['allowedHosts'];
blockedHosts?: NetworkConfig['blockedHosts'];
}

/** The `FilesystemConfig` fields LXC honors at start. */
export interface LxcFilesystemConfig {
readwritePaths?: string[];
Comment thread src/core/lxc/src/main.rs
Comment on lines +323 to +330
Err(ParseError::OneShot(_))
| Err(ParseError::OneShotMalformed(_))
| Err(ParseError::Decode(_)) => {
eprint!("Request error\n{}", logger.get_buffer());
process::exit(1);
}
Err(ParseError::StateAware(e)) => {
println!("{}", error_envelope_string(&e));

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

C# cannot construct a valid LXC provision request, and repeated in-process attached execs can leak stdin readers and consume later input.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs:404

  • Adding LXC here makes ProvisionSandbox(StateAwareContainment.Lxc, ...) publicly selectable, but the managed API cannot construct a valid LXC provision request. Native validation requires experimental.lxc.provision.distribution and release; null options emit neither field, and ValidateProvisionOptions rejects every existing non-null option type. Please add an LxcProvisionOptions type, accept it in validation, serialize both required fields into the backend provision block, and cover the resulting envelope in the C# tests.
        StateAwareContainment.Lxc => LxcStateAwareVersion,
  • Files reviewed: 95/97 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +1134 to +1141
let outcome = container.attach_run(
&request.script_code,
&request.working_directory,
&request.env,
true,
timeout,
Some(&marker),
);
The merge that folded telemetry into the shared engine helper left three call sites over the line limit and joined two use statements onto one line, which cargo fmt --check rejects. All three lint lanes stopped there, so clippy never ran on the merged tree.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a671670c-ad14-4302-8077-56bca655b58b
Every other Rust source under src/core is stored with LF. This file went in with CRLF, which .gitattributes already warns makes a Windows editor re-commit the whole file as a rewrite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a671670c-ad14-4302-8077-56bca655b58b

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 unusable through the newly exposed C# provision API, exact-contract support is incomplete, and IPv6 host-loopback enforcement can fail open.

Review details

Suppressed comments (4)

sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs:395

  • Lxc is now advertised as a provisionable C# backend, but BuildProvisionEnvelope has no LXC branch and ValidateProvisionOptions accepts no LXC-specific options. Passing null therefore emits no required experimental.lxc.provision.distribution/release and native validation rejects it; passing any options is rejected in managed code. Add an LxcProvisionOptions model, validation case, serialization branch, and API tests before exposing this enum value.
        StateAwareContainment.Lxc => LxcContainment,

src/core/wxc_common/src/wire.rs:581

  • This adds LXC only to the rolling wire model. The exact 0.9.0-alpha contract still has no LXC provision discriminator/variant (mxc_config_contract/src/dev/state_aware/provision), so its generated exact schema and TypeScript oracle reject this new lifecycle surface while the rolling schema accepts it. Mirror the new provision contract and adapter in the exact development model and regenerate both exact artifacts.
    src/backends/lxc/common/src/network_ingress.rs:718
  • This enforces hostLoopback: deny only for IPv4. Provision can adopt an existing single-veth container, so the assumption that every supported bridge has no IPv6 route is not guaranteed; an IPv6-enabled bridge leaves container-to-host connectivity open even though the GA contract requires hostLoopback in both directions. Resolve and block the host's IPv6 bridge addresses as well, or reject the start when IPv6 is active and that path cannot be closed.
        // IPv4 only, matching the one route LXC's default bridge gives the
        // container to the host. `lxc-net` ships an IPv4 address and leaves
        // `LXC_IPV6_ADDR` unset, leaving no IPv6 path to close.
        if Self::denies_host_loopback(policy, uses_directional_schema) {
            self.install_host_loopback_drop(&mut runner, logger)?;

src/core/lxc/src/main.rs:332

  • This new state-aware parse-failure path writes the envelope but never calls the shared configuration-rejection reporter. config_rejection.rs:4-9,15-21 requires every executor rejection—including pre-dispatch malformed JSON—to emit the common audit/telemetry record, and wxc/src/main.rs:1077-1088 does so for the equivalent branch. As written, malformed LXC lifecycle requests are absent from the audit stream.
  • Files reviewed: 95/97 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

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

Ingress remains workload-tamperable, timeout output violates the exec protocol, and both SDK surfaces expose incomplete LXC lifecycle APIs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

src/backends/lxc/common/src/state_aware.rs:1146

  • A timed-out exec reaches this branch as Err, after its PTY output may already have been streamed to stdout. lxc-exec then appends an error envelope to that same stdout, violating the documented pure-output-or-pure-envelope contract; execInSandboxAsync consequently returns an ordinary nonzero ExecResult and loses the timeout reason. Preserve timeout as an execution outcome/sentinel instead of converting it to MxcError after output has begun.
    sdk/node/src/state-aware-types.ts:119
  • This public start type omits the schema-0.8 directional fields that LXC now enforces. Since the helper sends 0.8.0-alpha, callers cannot express network.egress rules or network.ingress/hostLoopback through the typed state-aware SDK even though the backend and added fixtures support them. Include egress and ingress in this honored subset.
/** The `NetworkConfig` fields LXC honors at start. */
export interface LxcNetworkConfig {
  defaultPolicy?: NetworkConfig['defaultPolicy'];
  allowedHosts?: NetworkConfig['allowedHosts'];
  blockedHosts?: NetworkConfig['blockedHosts'];
}

sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24

  • Adding Lxc here exposes a backend that the .NET lifecycle API cannot provision. ValidateProvisionOptions accepts only null for LXC, while BuildProvisionEnvelope has no LXC case, so every accepted call omits the backend-required distribution and release fields and fails natively. Add an LXC-specific provision options type and serialize it under experimental.lxc.provision before advertising this enum value.
    /// <summary>Linux container managed by LXC.</summary>
    Lxc,
  • Files reviewed: 95/97 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +714 to +718
// IPv4 only, matching the one route LXC's default bridge gives the
// container to the host. `lxc-net` ships an IPv4 address and leaves
// `LXC_IPV6_ADDR` unset, leaving no IPv6 path to close.
if Self::denies_host_loopback(policy, uses_directional_schema) {
self.install_host_loopback_drop(&mut runner, logger)?;

@MGudgin Gudge (MGudgin) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

Requesting changes based on the containment and lifecycle blockers below. I re-fetched d3c94a0, confirmed its merge base is the PR base d130c369, and rechecked every finding against that range. Nineteen findings are attached to added lines; the three claim/scope findings are recorded here because they do not have valid code anchors.

The highest-priority fixes are: make the pre-start veth/firewall selector collision-safe, register provision for signal rollback, and make repeated attached exec reclaim its PTY threads/descriptors without adding a five-second delay to silent commands. The lifecycle lock and timeout-reap paths also need bounded failure behavior.

Findings outside the diff / claim locations

Medium (correctness, proportionality) - The statement “One-shot LXC is untouched” is inaccurate. claim_mismatch: one-shot still requires no experimental flag, but this PR adds malformed-environment, proxy/enforcement-mode, multi-interface, and host-resolution refusals and changes firewall setup/teardown behavior. Please narrow the claim and explicitly document the compatibility-impacting one-shot changes, or split them from this PR.

Medium (proportionality) - “Policy is enforced in both directions rather than outbound only” overstates the delta. claim_mismatch: inbound enforcement existed at the base revision; this PR adds host-loopback denial and return-path ownership behavior. Please describe that narrower change so reviewers and users understand the actual new guarantee.

Low (proportionality) - The shared one-shot firewall path grows beyond the stated lifecycle scope. introduced_by_change: deterministic pinning is lifecycle-related, but multi-interface refusal and hook-survival hardening also alter one-shot behavior. Please call out that blast radius in the PR description and release notes.

Verified clean, with receipts

  • Supply chain: Cargo.lock adds no package or version; the manifest changes only add edges to already locked semver and serde_json crates.
  • Experimental gating: state-aware LXC passes through require_experimental_optin before backend dispatch; one-shot remains ungated.
  • Telemetry/config rejection payloads do not include caller sandbox IDs or raw configuration values.
  • The parser explicitly rejects top-level lxc on state-aware requests and rejects experimental.lxc on one-shot requests, so the duplicated configuration locations do not currently have precedence ambiguity.

Verified pre-existing - not attributed to this PR

None retained as findings.

/// The name must fit the kernel `IFNAMSIZ` limit of 15 characters and be
/// unique per container, so a `mxcv` prefix (4) is followed by an
/// 11-character base36 hash token (15 chars total).
pub fn deterministic_veth_name(container_name: &str) -> String {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High (security) - Make the deterministic veth selector collision-safe.

Attribution: introduced_by_change — this PR derives the pre-start packet selector from caller-controlled containerId using an explicitly non-cryptographic FNV token.

If two accepted IDs map to the same veth name, the second start can install top-inserted FORWARD hooks against the first container's interface; a terminal ACCEPT chain can then preempt the victim's deny chain. Teardown also identifies return rules by that interface name.

Fix: derive the token from the existing SHA-256/base32 machinery and fail closed before installing hooks if the target interface already exists. Add collision/pre-claimed-interface tests.

};
if created {
container
.create(&config.distribution, &config.release)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High (reliability) - Register provision for signal rollback.

Attribution: introduced_by_change — provision performs the long-running lxc-create operation without registering an active sandbox with the new signal-cleanup watchdog.

A termination during image download can leave a partially created container. For a minted name, the caller never received the ID and cannot clean it up.

Fix: register a provisioning rollback before create(), destroy newly created state on signal, and clear the registration after completion.

// A one-shot executor exits immediately afterward and the OS reaps
// the thread, but an in-process caller does not, so the thread and
// the pty fd survive until its host exits.
let _ = join_with_timeout(output_thread, DRAIN_GRACE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High (performance/reliability) - Repeated attached exec leaks per-call PTY resources.

Attribution: newly_exposed_by_change — state-aware LXC makes this PTY path repeatable from long-lived in-process callers, while this branch explicitly abandons the reader thread and descriptor after timeout. The SIGWINCH and stdin forwarders are also not invocation-scoped.

Fix: retain cancellation handles, close duplicated PTY/self-pipe descriptors, stop and join all forwarding threads, and make the reader cancellable instead of abandoning it.

// 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High (performance) - Silent state-aware execs wait five seconds before proceeding.

Attribution: newly_exposed_by_change — this new repeated-exec call uses run_with_pty, whose readiness channel is signaled only after the first output byte. A successful silent command reaches EOF without signaling and waits the full default readiness timeout.

Fix: treat EOF/child exit as readiness completion, or skip output-readiness waiting for non-interactive execution. Add a timing regression test using true.

)))
}
};
let guard = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium (reliability/test coverage) - Bound and integration-test the cross-process lifecycle lock.

Attribution: introduced_by_change — the new lock blocks indefinitely and is held across external create/start/firewall commands. The current exclusion test runs in one process and does not prove the advertised cross-process serialization.

Fix: use nonblocking flock acquisition against an overall deadline with an actionable busy error, and add a live test launching two concurrent lxc-exec start processes against the same ID.

/// well-formed hostname currently resolves is a question about this
/// moment's DNS, and it stays with `resolve_host` and the unresolved-host
/// warning.
fn malformed_destination(policy: &ContainerPolicy) -> Option<(&'static str, String)> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium (proportionality) - Separate the warn-to-refuse policy change from lifecycle support.

Attribution: introduced_by_change — malformed/unresolvable network and environment cases are converted into hard refusals with a substantial independent test suite. This changes existing one-shot compatibility but is not required for container reuse.

Fix: split the refusal hardening into its own PR, or clearly justify and document the coordinated breaking change here.

// get a chain built against a name that never exists while its traffic
// ran unfiltered and MXC reported the policy as enforced.
let sole_kind = net.sole_kind.as_deref().unwrap_or_default();
if sole_kind != "veth" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low (maintainability) - Share the network-interface eligibility predicate.

Attribution: introduced_by_change — state-aware now independently encodes interface count/type eligibility while one-shot implements the same decision through a differently shaped fallback path.

Fix: extract one shared predicate and keep only the surface-specific fallback behavior separate.

let mut slot = lock_slot();
slot.name = Some(name.to_owned());
slot.veth = None;
slot.rollback = SignalRollback::DestroyContainer;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low (maintainability) - Consolidate the active-cleanup setters.

Attribution: introduced_by_change — the three setters repeat the same slot initialization and differ only in rollback mode and marker.

Fix: add a private set_active_with(name, rollback, marker) helper and delegate the public variants to it.

runner: &mut dyn CommandRunner,
logger: &mut Logger,
) -> Result<(), String> {
if !self.host_loopback_dropped {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low (test coverage) - Test host-loopback rule teardown semantics.

Attribution: introduced_by_change — this new teardown distinguishes “already absent” from retryable failure, but has no direct test comparable to the chain teardown suite.

Fix: verify absent-rule success clears the flag and real errors retain it for retry.

#[serde(rename_all = "camelCase")]
pub struct LxcExperimental {
/// State-aware provision-phase configuration.
pub provision: Option<LxcProvisionPhase>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium (API surface/maintainability) - Avoid two LXC JSON shapes for identical provisioning fields.

Attribution: introduced_by_change — experimental.lxc.provision duplicates the existing top-level lxc section's distribution and release using a second wire struct/schema definition.

The parser rejects mixing the two, so there is no precedence bug, but raw-config users now need lifecycle-dependent addresses and the definitions can drift.

Fix: preferably accept the canonical top-level lxc section for state-aware provision, using phase plus the experimental opt-in for disambiguation. If nested phase config is required by convention, reuse the same wire/schema type and add parity tests.

Brings in d636524, six commits behind. One conflicted file,
src/core/wxc/src/main.rs, from 2ecaa5f "(microsoft#969) Resolve the command before
the request is parsed".

This branch relocated three helpers out of main.rs into modules while main
still defines them inline, so upstream's side of two hunks was a duplicate of
config_rejection_reason_for (now wxc_common/src/config_rejection.rs),
state_aware_policy_identity, and sandbox_id_for_identity_record (now
mxc_engine/src/state_aware_telemetry.rs). Those hunks resolve to this branch.

The conflicted test block resolves as a split rather than a side. Four
incoming tests cover sandbox_id_for_identity_record, which no longer lives in
main.rs, and already exist by the same names in state_aware_telemetry.rs; they
are dropped here with no loss of coverage. microsoft#969's own
request_error_route_matches_the_output_conventions test and its resolve_with_cli
helper are kept, since microsoft#969's production path auto-merged intact.

backend_name_for_state_aware and emit_state_aware_early_rejection are removed.
microsoft#969 resolves the command before parsing, so a malformed CLI command override
now surfaces as a ParseError on the pre-parse path, and the post-parse error
path these two served no longer exists. This drops early-rejection telemetry
for that case: telemetry is initialized after parsing, so there is no active
session to emit against at the point the override is now rejected.

Verified with cargo check --workspace --all-targets on Windows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d276e7c7-103f-4a4f-ac6c-ad4790f3801b

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 C# surface cannot provision LXC, legacy mounts may survive tightened policies, and several lifecycle error and diagnostic paths are incorrect.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (8)

sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24

  • Adding Lxc here exposes a public lifecycle option that cannot produce a valid provision request. ValidateProvisionOptions accepts only null for LXC, and BuildProvisionEnvelope has no LXC branch, so the emitted envelope omits required experimental.lxc.provision.distribution and release fields and every ProvisionSandbox(StateAwareContainment.Lxc) call is rejected by the native parser. Please add an LXC-specific provision-options type and serialize those required fields before exposing this enum member.
    /// <summary>Linux container managed by LXC.</summary>
    Lxc,

src/backends/lxc/common/src/lxc_bindings.rs:609

  • Marker-only replacement leaves filesystem grants written by older MXC builds in place. The previous implementation appended unmarked lxc.mount.entry lines, and preserved containers keep that startup config; adopting or reusing one now treats those entries as user baseline and adds the new policy alongside them. A caller tightening/removing a prior mount can therefore retain host access that the current policy did not grant. This needs an explicit migration/rejection strategy for legacy MXC entries before marker-based replacement is safe.
    /// Marker comment written immediately above every `lxc.mount.entry` line
    /// that MXC itself adds, so
    /// [`replace_mxc_mount_entries`](Self::replace_mxc_mount_entries) can
    /// rewrite only MXC's own mounts and leave baseline entries the distro
    /// template or the user placed in the config untouched. It is a real LXC
    /// comment (`#`), so liblxc ignores it when parsing the file.
    const MXC_MOUNT_MARKER: &'static str = "# mxc-managed-mount";

src/core/lxc/src/main.rs:332

  • This state-aware parse-error branch bypasses log_config_rejected, unlike the corresponding wxc-exec branch. Malformed lifecycle requests handled here therefore produce neither the audit event nor the telemetry rejection event introduced by this PR, despite the shared rejection module's executor-wide contract. Route ParseError::StateAware through the rejection classifier/logger before emitting the envelope.
    src/backends/lxc/common/src/state_aware.rs:1183
  • This stop-phase logger drops the diagnostic sink installed by the shared state-aware wrapper, so firewall-cleanup diagnostics never reach --log-file or the audit pipe. Backend-local loggers on this path must inherit the thread sink.
    src/backends/lxc/common/src/state_aware.rs:1221
  • This deprovision-phase logger drops the diagnostic sink installed by the shared state-aware wrapper, so authoritative firewall-cleanup diagnostics never reach --log-file or the audit pipe. Backend-local loggers on this path must inherit the thread sink.
    src/backends/lxc/common/src/state_aware.rs:519
  • This branch reports any firewall setup failure after partial resource creation as invalid caller policy, although it is an operational iptables failure and policy validation has already completed. Typed clients receive a non-retryable policy_validation code and telemetry records UnsupportedField; return backend_error instead.
    src/backends/lxc/common/src/state_aware.rs:531
  • An existing/stale firewall chain is lifecycle state, not a malformed policy. Classifying this collision as policy_validation causes typed clients and rejection telemetry to blame the request even though the remediation in the message is to stop/deprovision and retry. Return a backend/state error code instead.
    src/backends/lxc/common/src/state_aware.rs:950
  • This provision-phase logger drops the diagnostic sink installed by the shared state-aware wrapper, so container-creation diagnostics never reach --log-file or the audit pipe. Backend-local loggers on this path must inherit the thread sink.
  • Files reviewed: 95/97 changed files
  • Comments generated: 5
  • Review effort level: Balanced

/// order means a dry run reports the same error the real start would, not merely
/// some error.
fn validate_start_policy(request: &ExecutionRequest) -> Result<(), MxcError> {
let mut logger = Logger::new(Mode::Buffer);
Comment on lines +485 to +487
Ok(false) => Err(MxcError::policy_validation(
"Failed to apply network firewall rules",
)),
Comment thread src/core/lxc/src/main.rs
}

let buffered = logger.get_buffer().to_string();
if !buffered.is_empty() {
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 +164 to +168
TIMEOUT_STARTED=$(date +%s)
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
A caller who asks a sandbox for no network gets a legacy network block
carrying `defaultPolicy: "block"` and no enforcement mode.  LXC refuses a
posture it cannot enforce.  That request is rejected at start, though the
caller never stated a mode to disagree with.

The SDK now names `firewall` for a legacy block that does not already name
one.  A mode the caller stated is left alone, and a 0.8 directional block
carries no enforcement mode by design and is untouched.

The constraints note in the state-aware API document said `enforcementMode`
was accepted but ignored by LXC.  That stopped being true when the backend
began refusing `capabilities` rather than running unenforced.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d276e7c7-103f-4a4f-ac6c-ad4790f3801b
@dhoehna

Darren Hoehna (dhoehna) commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Handoff — Darren is on vacation

Everything on his workstation is pushed. This comment is the map, written for
someone who was not here.

Where the work is

Every branch below is on a remote as of 2026-09-11. Nothing is stranded on the
workstation.

Branch Repo State
user/dahoehna/lxc-lifecycle-current dhoehna/mxc This PR. Built, tested, green — SDK unit suite 398 tests, 379 pass, 0 fail, 19 skipped; tsc clean.
user/dahoehna/lxc-reject-malformed-config microsoft/mxc Two commits pushed today: refuse network and environment settings LXC cannot enforce, plus the document correction. Not built here.
user/dahoehna/wb-ratelimit microsoft/mxc 63 commits from 2026-08-13 on crates.io / ESRP publishing that existed on no remote at all until today. Also on dhoehna/mxc as wb-ratelimit.
user/dahoehna/network-ga-schema microsoft/mxc WIP, unverified. 525 insertions across 6 files, committed and pushed unbuilt so it would not be stranded.
user/dahoehna/wip-total-denial-withhold-interface microsoft/mxc Rescued. Commit 75f918f3 was on a detached HEAD and would have been garbage-collected. Enforces total-denial by withholding the interface.
wb-ratelimit dhoehna/mxc 63 commits from 2026-08-13 on crates.io / ESRP publishing that existed on no remote at all until today.
user/dahoehna/wip-skilltest-run4 microsoft/mxc WIP, unverified. 335 insertions from a scratch tree; may be a discarded experiment.
user/dahoehna/wip-aitest-lxc-runner microsoft/mxc WIP, unverified. Was uncommitted on a local main; removes 1402 lines and adds 413. Deliberately not pushed to main.

Every branch above is on microsoft/mxc except this PR's own branch, which is
on dhoehna/mxc where it has always been.

The four wip-* branches and network-ga-schema were committed without being
built or tested
. Their commit messages say so. Treat them as preserved
material, not as proposed changes.

Two worktrees, mxc-inbound and mxc-inbound-deny, could not be pushed and
are not recoverable by anyone but Darren.
Their parent clone was deleted, so
no git metadata survives to say which commit they sat on. Their trees were
compared against every plausible base branch; the closest still differs by 99
and 107 files, which is branch drift rather than a delta, so no base can be
identified and no meaningful change can be extracted. Their source is archived
on his workstation only, at C:\git\_orphaned-worktrees-archive\. If something
in the inbound work turns out to be missing, it has to wait for him.

Two things that need a decision, not a keystroke

1. An egress regression in PR #1041, which nobody has raised on that PR.

Defending the first review finding on this PR, the argument was that egress is
safe because its chains sit on the host, out of the workload's reach. PR #1041
makes that false. It hooks egress into OUTPUT inside the container network
namespace
(network_iptables.rs:1955-1971, entered by nsenter -t <pid> -n at
:1363-1370). That is the same namespace a local Alpine experiment flushed
successfully, with CAP_NET_ADMIN present in the container's effective set. A
workload that can flush its own egress chain is not filtered by it.

This is the highest-value open item and it belongs on #1041, not here.

2. Which of #849 and #1041 lands first.

Nothing in either branch records the intended order, and three review findings
read differently depending on it. This needs an owner's decision.

The review threads — do not bulk-resolve

33 human review comments are unresolved. A pass was run to find which were made
moot by #1041. It proposed seven closures. An independent review overturned
all seven
, and the conclusion is that zero threads should be resolved.

The reason is worth stating, because it will come up again: authorship decides
mootness, not absence.
This PR creates most of the code under discussion —
state_aware.rs does not exist on the merge base and does not exist on #1041
either. Code that #849 adds and #1041 merely lacks survives reconciliation.
Four of the five proposed closures were argued from a gap #1041 never had
anything to lose in.

Two findings have partial fixes already on the branch (805afe23, 899de231),
but each original comment asked for more than shipped — a regression test in one
case, and one of three parts declined on the record in the other. They can be
replied to. They should not be closed.

Still open, and unstarted

  • Six review threads from 2026-09-04 have no reply yet, plus an exact-contract
    question about 0.9.0-alpha.
  • A behavior change from the deleted telemetry in merge 89fd4e29 was never
    posted as a comment.
  • The C# SDK posture question is unresolved.

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

The exact contract and SDK surfaces remain incomplete, and timeout and parse-rejection paths violate advertised lifecycle behavior.

Review details

Suppressed comments (5)

Previously missed (3) — in code that hasn't changed since the last review.

sdk/node/src/state-aware-types.ts:111

  • This type omits the schema-0.8 egress/ingress fields even though LXC defaults lifecycle requests to 0.8 and the backend implements those policies. It also omits enforcementMode, so callers overriding the request to 0.7 cannot request the firewall behavior described by this PR. The public typed API therefore cannot express supported LXC network policies.
    sdk/node/src/state-aware.ts:145
  • This typed-error guarantee does not hold for the new LXC timeout path. LXC may stream workload bytes to stdout before returning its timeout error; the executor appends the error envelope to that same stream, and tryParseErrorEnvelope(stdout) then fails whole-string parsing and resolves an ordinary ExecResult. The newly added unit test explicitly demonstrates this loss of the MxcError. Preserve a separately framed/channelled dispatch result (or otherwise parse it unambiguously) so timeout remains a typed dispatch failure.
    src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs:54
  • The exact 0.9 contract still has no LXC containment or provision variant: mxc_config_contract::dev::state_aware::provision::Containment::parse_exact rejects "lxc", while these adapter additions only initialize the new rolling-wire field to None. This leaves the new backend absent from exact parsing/codegen and will break when exact dispatch becomes authoritative. Add the LXC phase contracts and conversions alongside the rolling model.

sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24

  • Exposing Lxc here makes the C# API advertise a backend that it cannot provision. ValidateProvisionOptions accepts only null for LXC, but BuildProvisionEnvelope then emits no experimental.lxc.provision.distribution or release, so native validation always rejects the request; every non-null options subtype is rejected too. Add an LXC-specific provision options type and serialize its required fields (and start-time policy options), or do not expose this enum member yet.
    /// <summary>Linux container managed by LXC.</summary>
    Lxc,

src/core/lxc/src/main.rs:332

  • State-aware parse failures exit here before run_state_aware_with_telemetry, so they never call log_config_rejected. The Windows executor records the corresponding ParseError::StateAware refusal, and the shared telemetry path only covers errors after successful parsing. This makes malformed LXC lifecycle requests disappear from the configuration-rejection audit stream; classify and log the rejection before emitting the envelope.
  • Files reviewed: 96/98 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@SohamDas2021

Copy link
Copy Markdown
Contributor

We have decided to postpone this to >1.0, the work-item is being tracked in ADO

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants