Skip to content

[LXC] State-aware sandbox lifecycle - #849

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

[LXC] State-aware sandbox lifecycle#849
Darren Hoehna (dhoehna) wants to merge 95 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

📖 Description

Implements StatefulSandboxBackend for LXC, so a container can be driven through provision, start, exec, stop, and deprovision rather than only the one-shot spawn path.

  • Adds the five lifecycle phases, with start serialized per container under an advisory flock.
  • Adds network policy enforcement in both directions: the egress FORWARD chain in the host netns before the container starts, and the inbound INPUT chain inside the container's own netns once it is running.
  • Adds ContainerPolicy::requires_firewall(), so enforcement follows the policy — a block default, a non-empty host list, or a proxy — and a start with no network section denies by default.
  • Adds lxc to the Node SDK as a state-aware backend, with per-phase config types and LxcNetworkConfig narrowed to the three fields LXC enforces.
  • Adds two E2E scripts run against a live LXC host: the provision-through-deprovision lifecycle, and a network matrix over 15 cases and 16 checked-in configs.

🔍 Validation

  • cargo test --workspace --no-fail-fast — 2,785 passed, 2 failed. Both failures are pre-existing wslc_common tests that require an unlocked D: drive and reproduce at the base commit.
  • npm test (Node SDK) — 283 tests, 277 passed, 0 failed, 6 skipped.
  • cargo fmt --all -- --check and cargo clippy --workspace --all-targets — clean.
  • run_lxc_state_aware_network_test.sh on a live LXC host — 70 passed, 0 failed, 0 quarantined.
  • run_lxc_state_aware_test.sh on a live LXC host — 8 passed, 0 failed.

✅ 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 42 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/core/wxc_common/src/models.rs:641

  • This is a parse-derived presence bit, but unlike its peers it is included when ContainerPolicy is serialized (for example by diagnostic::redacted_request_json). Mark it skipped so internal parser state does not silently change the serialized diagnostic shape; filesystem_specified, network_specified, and network_mode_specified establish this convention at lines 627, 658, and 667.
    pub default_network_policy_present: bool,

tests/scripts/run_lxc_state_aware_network_test.sh:272

  • A failed deprovision is counted as a failure, but the sandbox ID is then cleared unconditionally, so the EXIT trap cannot retry cleanup and the live container can leak into later cases. Keep the ID until deprovision actually succeeds, as the lifecycle test already does in run_lxc_state_aware_test.sh:170-175.

Start was refused when a container put its only network interface at any
index other than lxc.net.0.  Nothing in the Linux roadmap asks for that.
The requirement was self-imposed: the pin key was the literal string
"lxc.net.0.veth.pair", so an interface anywhere else was called
unenforceable because the code did not know how to write to it.

configured_net_interfaces now reports the sole interface's index along
with its type, found by scanning lxc.net.N.type up to
NET_INDEX_SEARCH_LIMIT, and the pin is written to lxc.net.<N>.veth.pair.
liblxc offers no config dump and rejects "-c lxc.net.N" without a
sub-key, so the bounded scan is the only way to recover the index.  A
container numbering its interface past the bound is still refused, with a
message naming the range that was searched.

Refusal on a non-veth type is unchanged, and it now names the real index
rather than asserting the interface is missing from lxc.net.0.

E2E case 10 provisions a container, renumbers its sole interface to
lxc.net.3, and asserts the container starts, executes, and has both
FORWARD hooks on the veth its own ip link reports.  Restricting the scan
to index 0 fails case 10 and leaves case 3 green, so the case guards this
capability and nothing else.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af
Copilot AI review requested due to automatic review settings August 17, 2026 01:21

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 42 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (5)

sdk/node/src/state-aware.ts:198

  • For LXC dry-run execs, the executor keeps stdout as the protocol channel (exec_streams_stdout is false in src/core/lxc/src/main.rs:148), but this branch always inspects stderr. A dry-run validation/deserialization failure is therefore returned as a normal ExecResult instead of a typed MxcError. Select stderr only for non-dry-run LXC execs; dry-run should parse stdout like the other envelope-only paths.
    const errorEnvelope = backendKey === 'lxc'
      ? tryParseErrorEnvelopeFromLines(stderr)
      : tryParseErrorEnvelope(stdout);

tests/scripts/run_lxc_state_aware_network_test.sh:274

  • A failed deprovision still clears SANDBOX_ID, so the EXIT trap cannot retry cleanup and the test leaks the container into subsequent runs. Preserve the ID unless deprovision succeeds, as the lifecycle test already does.
    src/backends/lxc/common/src/state_aware.rs:270
  • This policy gate ignores policy.ui_specified, so raw state-aware requests can supply a UI lockdown and every LXC phase reports success without enforcing it. ContainerPolicy::ui defaults to deny and the parser deliberately preserves presence, so silently accepting it makes a security policy claim that the backend does not deliver. Reject any supplied UI block with policy_validation on start and the other phases, as unsupported-policy backends do.
fn reject_start_policy_on_other_phase(
    phase: &str,
    policy: &ContainerPolicy,
) -> Result<(), MxcError> {
    if has_filesystem_policy(policy) || has_network_policy(policy) {
        return Err(MxcError::policy_validation(format!(
            "LXC state-aware {phase} does not accept filesystem or network policy; pass it to start"
        )));

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

  • The new live matrix verifies only the host-side FORWARD chain. Removing this state-aware apply_ingress_policy call would leave every added lifecycle test green, even though the PR's key behavior is installing INPUT policy inside the container namespace. Add a state-aware live assertion that the MXCI chain and INPUT hook exist in the provisioned container's namespace (similar to run_lxc_inbound_deny_test.sh).
            // Inbound enforcement lands only once the container's network
            // namespace exists, so unlike the egress chain it comes after start.
            if let Err(e) = apply_ingress_policy(&container, container_name, request, &mut logger) {

src/backends/lxc/common/src/signal_cleanup.rs:124

  • NetworkOnly always runs StopContainer before cleanup. A signal can arrive after the FORWARD chain is installed but before container.start(); in that state lxc-stop -k returns nonzero for the already-stopped container, execute_rollback aborts, and the newly installed chain is stranded. Distinguish an already-stopped container from a failed stop so pre-start cancellation can still remove the owned firewall while preserving the fail-closed behavior for an unknown/live container.
        SignalRollback::NetworkOnly => {
            plan.push(RollbackStep::StopContainer);
            if owns_firewall {
                plan.push(RollbackStep::RemoveFirewall);
            }

An independent review of 825334a found the index scan could fail open.
The interface count and the interface index come from separate lxc-info
runs, so a config rewritten between them is pinned against a set of
interfaces that no longer exists: counted with one interface, scanned,
then a second interface arrives, and the container starts with only the
first one hooked while the second routes freely.

The window is not new -- the previous code read lxc.net and then
lxc.net.0.type through the same two separate runs -- but the scan widened
it, and it is closable.  configured_net_interfaces now brackets the scan
with a second read of lxc.net and refuses when the two do not match.
liblxc offers no lock and no atomic config read, so a summary that did not
survive the scan is the strongest available evidence that the index cannot
be trusted; refusing is the only answer that fails closed.

resolve_scanned_interface holds the decision as a pure function, matching
the file's existing split between subprocess wrappers and testable
parsers.  Six unit tests cover it, including the two that must NOT refuse:
a stable config with nothing found in range, and a trailing-newline
difference that is subprocess capture noise rather than a config change.
Defeating the comparison fails exactly the three refusal tests and leaves
the three pass-through tests green.

Also corrected a comment in state_aware.rs that still said only lxc.net.0
receives a pinned veth, and made case 10's config path honor LXC_PATH the
way resolve_lxcpath_with_env does.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af

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 42 out of 43 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

sdk/node/src/state-aware.ts:198

  • LXC dry-run exec failures are emitted on stdout (exec_streams_stdout is false when dry_run), but this branch always inspects stderr. Consequently execInSandboxAsync(..., { dryRun: true }) returns an ordinary nonzero ExecResult instead of throwing the typed validation error. Select stderr only for non-dry-run LXC execs.
    const errorEnvelope = backendKey === 'lxc'
      ? tryParseErrorEnvelopeFromLines(stderr)
      : tryParseErrorEnvelope(stdout);

src/backends/lxc/common/src/signal_cleanup.rs:101

  • This safety explanation contradicts the implemented rollback plan: the one-shot path no longer explicitly removes ingress rules before destroy; destroy removes their network namespace. The later DestroyContainer comment states the opposite correctly. Update this paragraph so future ordering changes are not based on the obsolete behavior.
/// The inbound rules are different in kind. They live inside the container's
/// own network namespace, reachable only by entering it through the init PID,
/// so they cease to exist when the container does. That is why only the
/// one-shot path removes them, and why it does so before `destroy` -- after
/// that there is no namespace left to enter. The stop path deliberately omits

tests/scripts/run_lxc_state_aware_network_test.sh:272

  • The sandbox ID is cleared even when deprovision fails, so the EXIT trap cannot retry cleanup and the failed case can leak a container into subsequent runs. Retain the ID unless deprovision succeeds, matching the lifecycle test's cleanup behavior.
    src/core/wxc_common/src/models.rs:641
  • This parse-derived presence bit is the only such ContainerPolicy field not marked #[serde(skip)] (unlike filesystem_specified, network_specified, and network_mode_specified). Serializing the public ExecutionRequest therefore exposes it, and deserializing can accept a caller-supplied value, contradicting the documented “never on the wire” contract.
    pub default_network_policy_present: bool,

Comment on lines +1152 to +1155
let sole = if count == 1 {
let located = self.locate_sole_net_interface()?;
let recheck = self.query_config_item("lxc.net")?;
resolve_scanned_interface(&summary, &recheck, located)?
The review of 1fbc2a9 showed the bracketing guard did not do what its
own comment claimed.  lxc.net prints interface values, not indices, so
the two sequences that matter both passed it: an interface moving from
lxc.net.0 to lxc.net.3 leaves the summary byte-identical while
invalidating the located index, and a macvlan that reads as veth only
while the scan runs leaves the summary identical while taking a veth pin
it can never answer to.  Either one starts a container whose traffic
never reaches the chain.

The recheck now covers the key actually depended on.  After the scan,
lxc.net.<located>.type is read back and required to still be the type the
scan found, and the summary comparison is kept because the two catch
different things: the summary catches an interface arriving, and the type
catches the located one leaving.  Defeating either check fails only its
own tests, so neither is carrying the other.

The doc comment no longer claims this closes the window.  liblxc offers
no lock and no atomic config read, so a writer that restores the config
before the recheck still passes -- that writer needs write access to a
root-owned config and could flush the firewall directly, so it is not the
case being defended against.  The case being defended against is a config
that moved and stayed moved, which would otherwise be pinned wrong in
silence.

Two of the earlier tests passed for the wrong reason: both asserted
Ok(None), which an implementation that never refuses also returns.  One
is renamed to what it actually pins -- a scan that found nothing is not a
read failure -- and the other now carries a located interface so it
proves pass-through rather than absence.

Also corrected deterministic_veth_name's doc comment, which still said
the pin goes to lxc.net.0.veth.pair.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af

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 42 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (3)

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

  • The live state-aware matrix exercises the new egress path only: its default-deny assertions inspect host FORWARD hooks and the terminal DROP, but nothing verifies that this state-aware call installs the container-netns INPUT chain or rolls back a failed ingress apply. The existing inbound test covers the one-shot runner, so this wiring could be removed while the new lifecycle suite remains green. Add a live state-aware inbound assertion (and a failed-apply rollback case) before relying on this security boundary.
            if let Err(e) = apply_ingress_policy(&container, container_name, request, &mut logger) {

tests/scripts/run_lxc_state_aware_network_test.sh:274

  • If deprovision fails, this still clears SANDBOX_ID, so the EXIT trap cannot retry and the test leaks the container it just reported failing to remove. Keep the ID until deprovision succeeds, as the lifecycle test already does.
    src/core/wxc_common/src/models.rs:641
  • This is documented as parse-derived and “never on the wire,” but unlike every adjacent presence bit it lacks serde(skip). Serializing the public ContainerPolicy therefore exposes default_network_policy_present, and deserialization accepts callers setting this internal parser fact. Skip it so only convert_wire_config can establish presence.
    pub default_network_policy_present: bool,

…ndex

Locating the sole interface by scanning lxc.net.<N> required a bound, and
liblxc offers nothing to bound it with: get_keys("lxc.net.") returns the
schema subkeys rather than the configured indices, no API dumps the merged
config, and any index from 0 to INT_MAX-1 is valid.  The scan therefore had
to invent a limit and refuse every container above it, which only moved the
hardcoded index from 0 to 31.

Pin the veth from a container-global lxc.hook.start-host instead.  The hook
resolves the peer interface from LXC_PID and renames it, so enforcement no
longer reads an index at all and the count and kind both come from the one
non-indexed lxc.net read.  That single read also removes the two-call race
the earlier review found, rather than guarding it.  A hook that cannot find
its interface exits nonzero and aborts the start, so the failure stays
closed.

Teardown then has to stop asking liblxc for the interface name.  The hook
renames the interface after liblxc created it, so lxc-info keeps reporting
the name it generated, and deletes replayed against that stale name matched
nothing: stop left the chain hooked and failed.  The name is derived from
the container name now, which is what put it there.

Deriving it exposed a second defect.  observe_existing recorded only the
plain hook, justified by teardown enumerating every form -- true only while
the authoritative path never had an interface to replay.  It now recognizes
each rule by the match it carries rather than by its text, because iptables
prints back RELATED,ESTABLISHED for a rule installed as ESTABLISHED,RELATED
and a rebuilt spec would miss every return rule.  With the interface known,
teardown reaches the two return rules that were previously a known strand,
and deprovision now leaves nothing behind.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af

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 42 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (3)

sdk/node/src/state-aware.ts:198

  • For LXC dry-run execs, lxc-exec does not reserve stdout for guest output: exec_streams_stdout is false when dry_run is set, so dispatch errors are emitted on stdout. This branch always checks only stderr for LXC, causing execInSandboxAsync(..., { dryRun: true }) to return an ordinary nonzero result instead of throwing the typed dispatch error. Select stderr only for non-dry-run LXC execs.
    const errorEnvelope = backendKey === 'lxc'
      ? tryParseErrorEnvelopeFromLines(stderr)
      : tryParseErrorEnvelope(stdout);

tests/scripts/run_lxc_state_aware_network_test.sh:390

  • The live state-aware default-deny check verifies only the host FORWARD hooks and terminal DROP. It never enters this started container's network namespace to assert that the new state-aware path installed and hooked its INPUT chain; the existing inbound script exercises the one-shot path instead. A regression that omits apply_ingress_policy from state-aware start would therefore leave all these cases green despite inbound being open. Add a namespace-level INPUT assertion for this case (and IPv6 when active).
    tests/scripts/run_lxc_state_aware_network_test.sh:274
  • These state resets run even when stop or deprovision fails. In that case the EXIT trap no longer retries the failed phase, so this root-run test can leak a running container and its firewall state into later cases or test runs. Clear each flag only after the corresponding phase succeeds, as the lifecycle test already does for deprovision.

The redesign that replaced the indexed veth pin with a container-global
start-host hook made teardown derive the interface name instead of
reading it back.  That is correct for the state-aware path, and a review
of the pushed commit found three places where it is not.

A one-shot container is renamed too.  The hook is written into the
container's durable config and stop never clears it, so a container that
was started state-aware and is later handed to the one-shot runner by
name is renamed on start while liblxc keeps reporting the name it
recorded before the rename.  Rules scoped to that recorded name filter
an interface that no longer answers to it, which leaves the container
unfiltered.  The runner now resolves the live name: it prefers the
deterministic name when the host confirms an interface by it, and falls
back to what liblxc recorded otherwise.

An unreadable FORWARD claimed only the plain hook.  Deriving the name
routes teardown down the branch that deletes exactly what it believes it
owns, so a blind delete of the plain hook could succeed, leave no hook
recorded, and let the chain be flushed while an unobserved physdev hook
still jumped into it.  An emptied chain that is still hooked returns to
its caller instead of reaching its own closing DROP.  Both hook forms
are now claimed.  A delete for a rule that never existed fails and is
held as a residual, which reports the stop as failed and retries -- the
losing side of a trade whose other side is an unfiltered container.

An interface-scoped ACCEPT is not ours on the interface name alone.  A
host rule accepting everything on the same interface was claimed, and
teardown then submitted this backend's fuller specification against it,
a delete that cannot match and is reported forever as a residual.  The
classifier now requires the connection-state match, compared as a set
because iptables prints RELATED,ESTABLISHED for a rule submitted as
ESTABLISHED,RELATED.

Three tests pin these: the two hook forms and the two return forms are
each claimed independently, and an ACCEPT carrying no connection-state
match is claimed by neither.  Each was mutation-tested and each kills
its mutant.  431 unit tests pass, the network matrix is 29/0, the
lifecycle suite 8/0, and the aggregate 82/0.  A full manual lifecycle
leaves zero residue.

The stale comment in the network matrix described the deleted pin and
the physdev hook as bridged-only, which was never true.  The assertions
are untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af

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 42 out of 43 changed files in this pull request and generated no new comments.

Suppressed comments (2)

tests/scripts/run_lxc_state_aware_network_test.sh:272

  • A failed deprovision is counted but then SANDBOX_ID is cleared. The next case can overwrite the only handle to that container, so the EXIT trap cannot retry cleanup and the test leaks a container. Abort while retaining the ID when deprovision fails; only clear it after success.
    src/core/wxc_common/src/models.rs:641
  • This parse-derived presence bit is serialized by ContainerPolicy, even though its documentation says it is never on the wire. The neighboring filesystem_specified, network_specified, and ui_specified fields are all skipped; leaving this one exposed leaks internal parser state into serialized policies and makes round-trips inconsistent. Mark it with #[serde(skip)] as well.
    pub default_network_policy_present: bool,

…ore flushing

A second review found the previous commit's three fixes each stopped one
step short.  All three shared a shape: a cheaper signal was standing in
for the question actually being asked.

Interface resolution asked the host, which cannot answer.  Whether an
interface of the pinned name exists on the host says the name is taken,
not that this container took it, and a transient failure of that probe
answered "no" and sent enforcement back to the name liblxc recorded --
the stale one, which is the defect the probe was added to close.  The
question belongs to the container: it carries the pin hook or it does
not, and a hook that cannot rename aborts the start, so the answer is
decisive.  `has_veth_pin_hook` reuses the comparison `ensure_veth_pin_hook`
writes with, so the reader and the writer cannot drift.  The host probe
is deleted rather than repaired -- it was answering the wrong question.
A config that cannot be read is now an error instead of a guess between
two names, one of which filters nothing.

The flush gate trusted a delete's exit code.  A `-D` that succeeds
removes one matching rule; iptables holds duplicates, and a jump can
carry qualifiers this backend never writes and still reach the chain.
The recovery path reconstructs ownership for a container whose state was
lost, which is where a second jump is likeliest to have accumulated, so
this is reachable rather than theoretical.  FORWARD is now re-read before
the flush, the same authority the enumerating path already answered to.

That re-read is strictly inhibitory: it may set a hook bit, never clear
one.  Written the other way it broke an existing test that fails a
delete and requires the chain not be flushed -- correctly, because an
authoritative re-read overrules a delete that genuinely failed on the
word of a probe that can be a moment stale.  The test was right and was
not touched.

The connection-state guard still claimed foreign rules.  A rule can
carry the interface, the state match, and a narrowing this backend never
writes -- a protocol, an address, a port, a negation -- and the delete
rebuilt from our own specification would not name it, so it could not
match and would be held forever as a residual.  Anything outside the
vocabulary these rules are built from is now somebody else's rule.

Two tests pin the new behavior and each kills its mutant; the
inhibitory-versus-authoritative gate is pinned by the existing test that
caught it.  432 unit tests pass, the network matrix is 29/0, the
lifecycle suite 8/0, and the aggregate 82/0, which covers the one-shot
path that now asks the container.  A full manual lifecycle leaves zero
residue.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af

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 42 out of 43 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/backends/lxc/common/src/signal_cleanup.rs:405

  • A signal can arrive after the FORWARD chain is created but before container.start(). In that state lxc-stop -k reports that the container is not running, this step returns false, and execute_rollback deliberately skips RemoveFirewall, stranding the chain and blocking the next start. Treat a positively stopped container as a successful stop; preserve the current fail-closed behavior when the state probe is unknown or killing a live container fails.
                RollbackStep::StopContainer => LxcContainer::new(&name, None).kill().is_ok(),

tests/scripts/run_lxc_state_aware_network_test.sh:272

  • This clears SANDBOX_ID even when deprovision fails. The next case then overwrites the only identifier the EXIT trap could retry, leaking the live container and potentially its firewall state. Stop the matrix on teardown failure while leaving the ID armed so the trap gets one final cleanup attempt.

Comment on lines +413 to +415
// The executor process exits right after a (state-aware) exec, so
// the OS reaps the detached thread.
let _ = join_with_timeout(output_thread, DRAIN_GRACE);
The network matrix covered defaultPolicy, enforcementMode firewall, and
allowedHosts.  Six start-phase fields the backend decides on had no case at
all, so a build that stopped honoring any of them would have gone unnoticed:
enforcementMode=both, an accepted allowedHosts, blockedHosts,
allowLocalNetwork, proxy, and the filesystem lists.

Cases 11 through 16 close that.  Each names the roadmap clause it pins:
item 13 (N1) for default-deny under `both`, item 15 (N3) for an enforceable
allowedHosts, item 16 (N4) for blockedHosts, item 14 (N2) for rejecting
allowLocalNetwork rather than guessing, item 17 (N5) for refusing a proxy
this backend cannot enforce, and D1/D4 for the filesystem lists the start
phase accepts and provision refuses.

Case 16 reads the filesystem result from inside the container rather than
from a zero exit: the host sentinel is readable through the readonly bind,
a write to it fails, and the denied directory is masked empty.

The proxy case needs both wire forms.  A state-aware start may not carry
`containment`, so the shared parser applies its backend-specific rules under
the default backend and refuses an external proxy url before any LXC code
runs.  The builtin form skips that rule but is testing-only scaffolding
gated centrally, so it needs --allow-testing-features to reach the LXC
verdict.  The case asserts that verdict by its message; accepting any
non-zero exit would have passed on the central gate alone, which is exactly
what it did until mutation testing caught it.

The existing fixture drift guard could not fail the run -- the script does
not set -e, so a failing python3 guard was ignored.  Both guard blocks now
check their exit status.

All six new cases are mutation-tested: removing each behavior they pin
flips exactly that case and no other.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c3de5422-8614-43fa-b628-d75c0bc968af

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

ContainerPolicy::filesystem_specified recorded whether the caller sent a
`filesystem` block at all, so an empty `filesystem: {}` counted as a supplied
policy and the phases that document "no filesystem section" refused it.

Nothing needed that distinction.  The three peer backends already gate on the
path lists alone -- isolation_session/common/src/policy.rs:82, windows_sandbox/
lifecycle/src/state_aware.rs:212, and wslc/common/src/policy.rs:105 -- and LXC
was the only backend keying off presence, so removing the bit makes it agree
with its siblings rather than diverge from them.

has_filesystem_policy now answers from readwrite_paths, readonly_paths, and
denied_paths.  An empty block is therefore indistinguishable from an absent one
and is accepted at any phase, so the phase matrix and the case 8 wording say
"non-empty path list", and the E2E case that pinned the empty-block refusal is
removed along with the bit it was pinning.

The unit test asserting the empty block was still seen is replaced by one that
exercises each path list on its own, so a clause dropped from the OR is still
caught.

Verified: cargo test -p wxc_common -p lxc_common under WSL, 1142 passed and 0
failed; cargo check --workspace --all-targets on Windows, exit 0; rustfmt and
bash -n clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d01f4daa-736b-48a4-9c78-2ed406f85c08

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 48 out of 49 changed files in this pull request and generated no new comments.

Suppressed comments (3)

tests/scripts/run_lxc_state_aware_network_test.sh:314

  • This clears SANDBOX_ID even when deprovision fails, so the EXIT trap cannot retry and the failed case leaks its container into subsequent cases/runs. The lifecycle test already preserves the ID on failure for this reason; do the same here.
    tests/scripts/run_lxc_state_aware_network_test.sh:434
  • The assert_default_deny path verifies only the host-side FORWARD chain. If the new apply_ingress_policy call were removed, cases 3/10/11/12 would still pass, so the state-aware INPUT-chain behavior claimed by this suite is not covered. Add a container-netns iptables assertion or an inbound traffic probe that fails when the INPUT hook/terminal DROP is absent.
    sdk/node/src/state-aware-types.ts:158
  • FilesystemConfig also exposes the SDK-only clearPolicyOnExit field, but state-aware envelopes forward filesystem unchanged and Rust's wire::Filesystem rejects that field via deny_unknown_fields. As written, a type-correct LXC start can therefore fail during parsing. Narrow this property to the three actual wire fields, just as LxcNetworkConfig excludes its SDK-only field.
  filesystem?: FilesystemConfig;

LXC used network.enforcementMode to decide whether to enforce network
policy at all.  The mode defaults to "capabilities", so a start carrying
defaultPolicy: block, allowedHosts, or blockedHosts parsed those fields,
installed nothing, and returned success.  The caller was told the sandbox
was network-restricted while the container kept unrestricted egress.

No other Linux backend works this way.  Bubblewrap and Seatbelt enforce
from the policy and treat the mode as a hint, and WSLC never reads the
mode at all.  LXC was the only backend where a field describing how to
enforce could switch enforcement off.

Enforcement is now decided by ContainerPolicy::requires_firewall(): a
block default, a non-empty allowed or blocked host list, or an enabled
proxy.  The struct default for default_network_policy is already Block,
so a request with no network section is a deny-all.  Omitted and empty
both deny.  enforcementMode is still parsed and accepted, and an explicit
"capabilities" is accepted rather than rejected, so a caller can get more
enforcement than asked for and never less.

Ingress stays unconditional while egress is policy-gated.  The asymmetry
is deliberate: firewall mode with defaultPolicy allow and no host lists
installs an inbound chain today, and gating ingress on the same predicate
would silently remove it.

Deliberate behavior changes:

- A start with no network section installs a deny-all egress chain
  instead of nothing.
- proxy no longer requires firewall or both.  LXC refuses network.proxy
  outright as unsupported, so the mode rule sat on top of an
  unconditional refusal and could never be the reason a request failed.
- A start against an already-running container returns already_started
  whenever the policy requires enforcement that cannot be applied to a
  running container, which is what the documented error code means.
- A missing init PID is fatal on every path rather than only when a
  firewall mode was named.

default_network_policy_present is deleted.  It recorded whether the
caller wrote a network section, and no downstream consumer produced a
different result from it.  Closes microsoft#890.

Verified on live LXC containers: 70 passed, 0 failed.  A start with no
network key and a start with an empty allowedHosts list both end in a
FORWARD chain that hooks the container veth on the physdev and direct
paths and terminates in DROP.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff

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 53 out of 54 changed files in this pull request and generated no new comments.

Suppressed comments (3)

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

  • This contract contradicts the PR description, which says restrictive LXC start policies require enforcementMode: 'firewall' (or 'both'). The implementation and tests instead accept omitted/capabilities modes and ignore the field. Please align the PR requirements with the shipped API, or restore the stated validation; currently the intended public behavior is ambiguous.
 * - `enforcementMode` is accepted, parsed, and ignored.  LXC enforces from
 *   `defaultPolicy`, `allowedHosts`, and `blockedHosts` alone, so no
 *   combination of this field with those is worth rejecting here.

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

  • FilesystemConfig also exposes the SDK-only clearPolicyOnExit field, but buildStateAwareEnvelope copies it into the top-level filesystem object and Rust's wire::Filesystem rejects unknown fields. A type-valid LXC start using that option therefore fails at runtime. Narrow this property to the three wire path lists, as was already done for LxcNetworkConfig to exclude SDK-only fields.
  filesystem?: FilesystemConfig;

tests/scripts/run_lxc_state_aware_network_test.sh:425

  • The live state-aware matrix only inspects the host FORWARD chain. It never verifies the new container-namespace INPUT hook or terminal DROP, so removing the apply_ingress_policy call would still leave this suite green—the exact regression this PR is meant to prevent. Add a state-aware assertion that enters the provisioned container's network namespace and checks the INPUT chain (ideally with traffic-level IPv4/IPv6 probes).

`tryParseErrorEnvelopeFromLines` was an exported symbol on
`state-aware-helper.ts`, which is one of the paths that auto-requests
Mxc-Architect-Team review.  What it held was a single expression: take the
last non-empty line of stderr and hand it to `tryParseErrorEnvelope`.  The
loop read as a search, but it returned unconditionally on the first non-empty
line from the end, so the `continue` only ever skipped the trailing blank that
`split('\n')` produces because the executor ends its output with a newline.

It had exactly one caller, was not re-exported from `index.ts`, and no test
imported it -- the three tests that pin this behavior all drive it through
`execInSandboxAsync`.  So the export bought a new public name on a contract
surface and nothing else.

Worse, the name was already taken.  `sandbox.ts` has had a private
`tryParseErrorEnvelopeFromLines` since the SDK folder was created, and it does
something different: it scans forward for the *first* parseable envelope
anywhere in a combined PTY stream and returns an `MxcError`.  Two
architect-owned files now disagreed about what that name means.

The reasoning that was load-bearing has moved to the call site, which already
carried the channel-ownership argument.  The rest of the docstring restated
what the call site says.

No behavior change: `tryParseErrorEnvelope` already trims its input and already
returns null on anything that is not a well-formed envelope, so empty stderr,
envelope-only, buffer-then-envelope, envelope-then-guest-output, and trailing
blank lines all resolve exactly as before.  Node SDK builds clean and all 286
tests pass, including the four that pin this path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff

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 53 out of 54 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

tests/scripts/run_lxc_state_aware_network_test.sh:305

  • Do not discard the sandbox ID when deprovision fails. Clearing it here disarms the EXIT trap's retry and leaks the container/firewall state; the lifecycle test correctly retains its ID on failure. Capture the return code and clear SANDBOX_ID only after success.
    src/backends/lxc/common/src/state_aware.rs:948
  • The live state-aware matrix never verifies this newly wired ingress half: it inspects only host FORWARD rules, while run_lxc_inbound_deny_test.sh exercises the one-shot path. Removing this call would leave the new matrix green. Add a state-aware check that enters the started container's netns and confirms the MXCI chain is hooked from INPUT (for both usable families).
            // Inbound enforcement lands only once the container's network
            // namespace exists, so unlike the egress chain it comes after start.
            if let Err(e) = apply_ingress_policy(&container, container_name, request, &mut logger) {

Comment on lines +324 to +326
// Ingress installs unconditionally, so the init PID is mandatory for
// every start: LXC enters the container's netns through it, and the
// ingress manager cannot even be constructed without one.
The cross-backend contract reserves stdout for the response envelope in
every phase, including a failed exec: mxc-state-aware-sandbox-api.md
section 7.3 states "stdout is authoritative: for exec it carries either
the script's output (success) or exactly one envelope (failure)", and
section 9.4 says an Err "write[s] the JSON to stdout".  stderr is
informational and carries only the diagnostic buffer.

The LXC executor did not follow that.  It computed exec_streams_stdout
and rerouted the envelope to stderr for a streaming exec, on the
reasoning that the PTY relay had made stdout an unclean JSON channel.
That symbol existed in exactly one file in the repo.  Everything
downstream was scaffolding holding it up: the SDK needed a
backendKey === 'lxc' branch reading only the last non-empty line of
stderr, and the state-aware parse arm emitted the envelope twice so
either kind of reader would find it.

wxc, the other state-aware executor, does none of this, and its own
comment says the lxc entry point is meant to share the orchestration
"verbatim -- a Linux lifecycle is observed exactly like a Windows one,
and the two cannot drift apart".  run_state_aware_main is now
structurally identical to wxc's.

The lifecycle E2E already documented the contract LXC was not honoring:
run_lxc_state_aware_test.sh calls the executor with no redirection under
a comment reading "Envelope goes to stdout, diagnostics to stderr, so
the caller can parse stdout directly".

Behavior changes, both shared with every other backend:

- A guest that prints an {error} document and exits nonzero is now
  surfaced to the caller as that error.  LXC alone had hardened against
  this; the contract accepts it, and the script is the caller's own.
- A script killed by its timeout after streaming output returns an
  ExecResult rather than throwing, because stdout then holds guest
  output and the envelope and whole-string parsing matches neither.
  This is a gap in section 7.3 shared by all backends, pinned by a
  characterization test so closing it has to be deliberate.

Five LXC-specific SDK tests collapse to two: one proving channel
separation, one recording that gap.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff
…ifecycle-current

# Conflicts:
#	.github/copilot-instructions.md

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 54 out of 55 changed files in this pull request and generated no new comments.

Suppressed comments (4)

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

  • This channel description contradicts the new executor and the implementation below: lxc-exec writes dispatch error envelopes to stdout (src/core/lxc/src/main.rs:166), and execInSandboxAsync parses stdout at line 174. The streaming API also exposes the PTY's combined data rather than selecting a backend-specific error channel. Please document stdout here so callers do not look for LXC protocol errors on stderr.
 * On dispatch failure the executor emits a single error envelope; the SDK does
 * not parse it here — callers consuming `IPty.onData` see the raw bytes. The
 * channel is backend-specific: LXC puts it on stderr, because stdout is
 * carrying the container's raw output, while Windows Sandbox, IsolationSession,
 * and WSLc keep stdout as the executor's own channel and emit it there. Use
 * `execInSandboxAsync` when typed-error throwing is needed — it already selects
 * the right channel per backend.

sdk/node/src/state-aware.ts:153

  • This still claims LXC errors are read from stderr, but the changed LXC entry point emits the envelope on stdout and this function only calls tryParseErrorEnvelope(stdout). Correct the public contract to match the actual parser.
 * field set) when the executor reports a dispatch failure, recognised by
 * exit != 0 together with a complete `{error}` envelope on the channel that
 * backend's executor owns -- stderr for LXC, stdout for the others.

tests/scripts/run_lxc_state_aware_network_test.sh:305

  • This clears the only cleanup handle even when deprovision fails. The next case overwrites SANDBOX_ID, so the EXIT trap cannot retry and the leaked container can contaminate later cases. Keep the ID and stop the matrix on teardown failure so the trap gets one final retry.
    .github/copilot-instructions.md:186
  • “In a firewall mode” is now incorrect: this PR deliberately ignores enforcementMode and installs egress filtering whenever requires_firewall() is true, including the default-deny request with no network section. Since this file defines repository guidance, describe the policy-driven predicate instead.
| LXC | `lxc-exec` | Linux | `core/lxc/src/main.rs` + `backends/lxc/common/` — supports both **one-shot** (single-invocation lifecycle) and **state-aware** (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. State-aware lives in `backends/lxc/common/src/state_aware.rs` (`LxcStateAwareRunner`) and needs **no daemon**: the container outlives each phase process in the LXC runtime, so `sandboxId` is just `lxc:<containerName>` and every phase re-acquires the handle with `LxcContainer::new`. In a firewall mode the host-side veth name is derived from the container name and pinned by a container-global `lxc.hook.start-host` installed **before** start, so the iptables FORWARD chain is scoped before the interface exists; the hook resolves the container's peer interface from `$LXC_PID` and renames it to that name, and exits nonzero — aborting the start — if it cannot find one. The interface set is read from liblxc (`lxc-info -c`) rather than by parsing the container's config, so an `lxc.include` that declares interfaces elsewhere is resolved rather than refused, and no interface *index* is read at all, so a container numbering its interface `lxc.net.3` is enforced exactly as one using `lxc.net.0`. Start is refused when the container declares anything other than exactly one interface, or does not declare that interface's type as `veth` (an undeclared type is refused too, since absence is not evidence of a veth). Teardown derives that same name rather than asking `lxc-info`, which keeps reporting the name liblxc recorded before the hook renamed it, and is ownership-scoped, so a concurrent start's chain is left alone; mount cleanup clears only MXC-marked `lxc.mount.entry` lines. Inbound default-deny (`IngressManager`) is installed on both paths; on the state-aware path it goes on after `container.start()` because the chain lives in the container's network namespace and needs its init PID, and a failure there rolls the start back. Start, stop, and deprovision each take a per-sandbox `flock` in the LXC root, so a teardown cannot strip a start's firewall in the window before the container runs. See `docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md`. |

…ents

LxcNetworkConfig exists to narrow the shared network section to the fields an
LXC caller can usefully set, and it was carrying one its own docstring called
"accepted, parsed, and ignored".

No LXC code reads it.  The only production reads of network_enforcement_mode in
the tree are the Bubblewrap and Seatbelt proxy guards (config_parser.rs:1178
and :1197); every other occurrence is the write from the wire (:1106) or sits
after a #[cfg(test)] marker.  The live network matrix shows the same thing as
output: cases 2, 3, and 11 differ only in enforcementMode -- omitted, firewall,
and both -- and all three end in the same DROP chain.

Seatbelt is the contrast that makes the case.  It ignores the mode for
enforcement too, but still rejects proxy plus a firewall mode, so the field
changes what a caller sees.  LXC's equivalent guard is gone, so for LXC the
field now changes nothing at all.  A setting with no effect is worse than no
setting.

The wire still accepts the field, so a network object shared with another
backend keeps parsing and the checked-in configs that carry it are untouched.

Two doc comments in state-aware.ts still described the error envelope as
arriving on stderr for LXC.  That stopped being true when the executor moved to
stdout, and one of them contradicted a comment three lines below it in the same
function.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff

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 54 out of 55 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

tests/scripts/run_lxc_state_aware_network_test.sh:743

  • This case never verifies that allowedHosts produces an ACCEPT rule: it only checks that the chain is hooked and ends in DROP, which is identical to default-deny with the allow list ignored. Add a positive connectivity check to a controlled allowed endpoint (plus a denied control), or inspect the destination-specific ACCEPT rule; case 12 has the same gap.
    tests/scripts/run_lxc_state_aware_network_test.sh:305
  • A failed deprovision still clears SANDBOX_ID, so the EXIT trap cannot retry cleanup and the container leaks into later cases/runs. Preserve the ID unless deprovision succeeds, as the lifecycle test already does in tests/scripts/run_lxc_state_aware_test.sh:168-175.
    tests/scripts/run_lxc_state_aware_network_test.sh:793
  • This blocklist assertion is vacuous because omitting defaultPolicy inherits default block, so evil.example.com is denied even if blockedHosts is completely ignored. Exercise the list under defaultPolicy: allow and verify both the blocked destination and an allowed control, or inspect the explicit destination DROP rule.

Comment on lines +542 to +545
rm -rf "$FS_RO_DIR" "$FS_DENIED_DIR"
mkdir -p "$FS_RO_DIR" "$FS_DENIED_DIR" || fail_now "case $case_no could not create its host fixture directories"
echo "$FS_SENTINEL" > "$FS_RO_DIR/sentinel"
echo "$FS_SENTINEL" > "$FS_DENIED_DIR/sentinel"
LXC enforces network policy from the policy itself and the default is deny-all,
so every LXC run now installs an egress chain even when the request carries no
network section.  A bridged veth only reaches FORWARD while br_netfilter
delivers bridged packets to iptables, and the SDK integration lanes never
enabled it, so the backend correctly refused to report success for a policy it
could not enforce:

  Container veth vethfPaRw9 is attached to a bridge but bridged packets are not
  delivered to iptables (/proc/sys/net/bridge/bridge-nf-call-iptables is absent
  or 0), so chain MXC-lxc-pro-... could never be reached from FORWARD.

Every LXC test failed at the backend probe with exit 255, across all three
schema versions.  The dedicated LXC E2E workflow already does this
(lxc-e2e.yml:44-48) and is green, so this is the same two sysctls on the lane
that was missing them.

Its `iptables -P FORWARD ACCEPT` step is deliberately not copied.  That exists
so the deny cases cannot pass vacuously under Docker's DROP policy, and these
lanes skip the network-dependent LXC tests entirely.

The Azure Pipelines lane has the same gap and gets the same fix, tolerated
rather than required, because a hosted pool may forbid loading modules.  Where
it does, the LXC tests fail exactly as they already would.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 516b2545-8130-4af2-b5e8-e9f6c6d591ff

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 56 out of 57 changed files in this pull request and generated no new comments.

Suppressed comments (2)

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

  • FilesystemConfig also exposes the SDK-only clearPolicyOnExit field. buildStateAwareEnvelope forwards this object unchanged, while Rust's wire::Filesystem uses deny_unknown_fields and has no such member, so a configuration accepted by this new public type fails at runtime as malformed. Narrow the filesystem type just as the network type is narrowed.
  filesystem?: FilesystemConfig;

tests/scripts/run_lxc_state_aware_network_test.sh:307

  • The sandbox ID is cleared even when deprovision fails, which disarms the EXIT trap and can leave a container behind. The same issue occurs for SANDBOX_STARTED: a failed stop is recorded as stopped, so cleanup will not retry it. Retain both states until their corresponding phase succeeds.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants