[WSLC] Reject remaining policy fields the backend cannot honor - #1058
Conversation
WSLc accepted four policy fields, carried them into the runner, and then
never read them. A caller asking for a UI lockdown, a firewall enforcement
mode, inbound local-network access, or policy preservation got a container
that silently did not have the posture they asked for. Close each hole with
an explicit policy_validation rejection.
Every rejection aborts before anything is created. All three entry surfaces
validate first: ScriptRunner::run ahead of execute, SandboxBackend::spawn
ahead of start_container, and the state-aware dispatcher ahead of each phase
body -- and connect_daemon() lives inside provision(), so a refused provision
never even spawns the daemon.
ui rejected on every phase, both surfaces
network.allowLocalNetwork rejected at state-aware provision (one-shot
already rejected it)
network.enforcementMode firewall/both rejected; capabilities accepted
lifecycle.preservePolicy rejected on one-shot (state-aware already
rejects the whole lifecycle section at parse)
ui is rejected by presence, not value. UiPolicy::default() is full lockdown,
so an explicitly supplied lockdown ui is indistinguishable by value from an
absent one -- a value-based check would let the single most restrictive
request a caller can write through unenforced. This uses the parse-derived
ContainerPolicy::ui_specified flag, mirroring IsolationSession.
enforcementMode and preservePolicy are rejected by value instead, because
their defaults honestly describe WSLc's behavior: an all-or-nothing container
network with nothing per-host to enforce, and auto-remove teardown. Refusing
those for mere presence would be dishonest.
destroyOnExit stays honored -- it selects WSLC_CONTAINER_FLAG_AUTO_REMOVE --
so only preservePolicy is refused. A blanket lifecycle rejection would have
broken the wslc_destroy_on_exit_{true,false} configs; a test pins both values
still passing.
The two allowLocalNetwork messages differ deliberately. One-shot points
callers at experimental.wslc portMappings, but WslcProvisionPhase has no
portMappings field at all, so repeating that advice on the state-aware
surface would be a lie.
Rejection ordering is filesystem -> ui -> network, documented in the policy.rs
module header and pinned by precedence tests.
No wire, schema, or parser-gating changes: this is a domain-model behavior
change only, so it lands while WSLc is still experimental and the nightly
WSLc suite exercises the new rejections before the surface moves.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c23ddc86-3848-452e-8355-e11d2ffa9b7f
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Adds fail-closed WSLc validation for policy fields the backend cannot enforce.
Changes:
- Rejects unsupported UI, network enforcement, local-network, and lifecycle settings.
- Applies validation before one-shot, streaming, and state-aware execution.
- Adds regression tests and updates WSLc documentation.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/core/wxc_common/src/models.rs |
Documents WSLc use of UI presence tracking. |
src/backends/wslc/common/src/wsl_container_runner.rs |
Adds one-shot policy validation. |
src/backends/wslc/common/src/state_aware.rs |
Tests state-aware validation hooks. |
src/backends/wslc/common/src/sandbox.rs |
Tests pre-start streaming validation. |
src/backends/wslc/common/src/policy.rs |
Implements shared WSLc policy rejection rules. |
docs/wsl/wslc-state-aware.md |
Updates the state-aware policy matrix. |
docs/wsl/wsl-container-getting-started.md |
Documents unsupported one-shot policy fields. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Suppressed comments (1)
src/backends/wslc/common/src/wsl_container_runner.rs:2600
- This test locks in incorrect behavior:
destroyOnExit=falseonly omitsWSLC_CONTAINER_FLAG_AUTO_REMOVE; the one-shotWslcSessionGuardstill terminates the session at the end of the run and reaps the session-scoped container. Update this case to expect a policy rejection forfalse, while retaining the acceptance check fortrue.
for destroy_on_exit in [true, false] {
cc7007a to
fa97faa
Compare
Darren Hoehna (dhoehna)
left a comment
There was a problem hiding this comment.
Look through some of the bigger comment sections and reduce them down.
There was a problem hiding this comment.
🔵 Needs a closer look
The shared parser preempts the new state-aware allowLocalNetwork validator and still returns inappropriate one-shot guidance.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/backends/wslc/common/src/policy.rs:83
- The real state-aware provision path never reaches this surface-specific check.
convert_wire_configstill rejectsallowLocalNetwork=truefor every WSLc request inconfig_parser.rs:1070-1095, before state-aware dispatch invokesvalidate_provision; that earlier error also tells state-aware callers to useexperimental.wslc.portMappings, the dead-end this helper is intended to avoid. Exclude state-aware provision from the shared parser gate (while retaining the one-shot gate), then add a parsed state-aware request test so the externally observable path receives this validator's message.
- Files reviewed: 13/13 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Branden Bonaby (bbonaby)
left a comment
There was a problem hiding this comment.
mostly still about comment clean up, other than that we should be ok
62ed1cd to
cd8c127
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The lifecycle E2E test does not assert that the rejected payload never executes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Balanced
| $null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_false_rejected.json" ` | ||
| -ExpectedExit -1 ` | ||
| -OutputContains "destroyOnExit=false")) |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c23ddc86-3848-452e-8355-e11d2ffa9b7f
cd8c127 to
a9fa699
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The new E2E rejection test does not assert that the forbidden payload remains unexecuted.
Review details
Suppressed comments (1)
tests/scripts/run_wslc_all_tests.ps1:359
- This E2E case does not actually enforce the stated “payload must never run” condition:
Run-WslcTestonly checks the exit code and required substring, so output containing both the rejection and theFAIL:marker would still pass. Add an explicit negative output assertion via the existingPostExitCheckhook.
$null = $results.Add((Run-WslcTest "wslc_destroy_on_exit_false_rejected.json" `
-ExpectedExit -1 `
-OutputContains "destroyOnExit=false"))
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Resolves the conflicts caused by #1058 (WSLc rejection hardening) being squash-merged to main as ab8930d while this branch already carried its original commits, plus four other changes that landed alongside it. Twelve files conflicted. Most were "both sides added a sibling", so the resolution keeps both additions rather than either side alone: - probe.rs / main.rs / platform.ts (+ tests): keep both the wslc_available gate from this branch and the hyperlight_available gate from #1059. - platform.test.ts: split the merged block into separate "wslc availability gate" and "hyperlight availability gate" describes. - sandbox.test.ts: keep main's new Linux backend-failure-reason test alongside the renamed windows_sandbox test. - docs/schema.md: combine two independent promotions -- top-level wslc from this branch and top-level seatbelt (with extraMachLookups) from #1033 -- leaving experimental holding only telemetry. - sdk/node/README.md: keep both the Windows wslc-gating sentence and main's Linux unavailableReasons sentence. The remaining five (policy.rs, wsl_container_runner.rs, the two WSLc test configs, copilot-instructions.md) are pure promotion conflicts where this branch's form -- wslc.portMappings, schema 0.9.0-alpha, the top-level wslc block -- is the correct one. Verified: cargo check/clippy/fmt/test --workspace --features wslc, the six versioning and codegen gates, npm run build + npm test (331 tests), and dotnet build of the C# SDK all pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c28d1c5e-fe3e-4634-9dd7-246528cc9edd
📖 Description
.github/copilot-instructions.md.Summary
WSLc accepted five policy fields, carried them into the runner, and never read them. A caller asking for a UI lockdown, a firewall enforcement mode, inbound local-network access, policy preservation, or a container that outlives the run got a container that silently did not have the posture they asked for. Each is now an explicit
policy_validationrejection.uinetwork.allowLocalNetworknetwork.enforcementModefirewall/bothrejected;capabilitiesacceptedlifecycle.preservePolicylifecycle.destroyOnExitfalseaccepted, behaved astruefalserejected on one-shot;trueacceptedRejection means abort, not teardown. All three entry surfaces validate before anything is created:
ScriptRunner::runahead ofexecute,SandboxBackend::spawnahead ofstart_container, and the state-aware dispatcher ahead of each phase body — andconnect_daemon()lives insideprovision(), so a refused provision never even spawns the daemon.uiis rejected by presence, not value. The parser materializes a full-lockdownuiblock into every request, so an explicitly supplied lockdownuiis indistinguishable by value from an absent one — a value-based check would reject every WSLc config in the repo while letting the most restrictive request a caller can write through unenforced. This uses the parse-derivedContainerPolicy::ui_specifiedflag, mirroring IsolationSession.enforcementMode,preservePolicy, anddestroyOnExitare rejected by value instead, because their defaults honestly describe WSLc's behavior —destroyOnExitdefaults totrue, so bare configs are untouched.destroyOnExit: falsecorrects a claim this PR originally made. SelectingWSLC_CONTAINER_FLAG_AUTO_REMOVEis not the same as honoring the field:StartedContainerowns theWslcSessionGuard, whoseDropterminates the session — and with it the session-scoped container — regardless of the flag, and the WSLc SDK has no cross-process re-attach.trueandfalsetherefore produce an identical observable outcome, which the repo's own fixture had already admitted in a comment. Acceptingfalsepromised a container that was already gone, so it is now refused and that fixture converted into a rejection test.No wire, schema, or parser-gating changes — this is a domain-model behavior change only, so it lands while WSLc is still experimental.
Validation
wslc_common211 tests (with and withoutlink-wslcsdk),wxc_common983, cleanfmtand clippy onwslc_common/wxc_common/wxc --features wslc. All 43tests/configs/wslc*.jsonpushed through the real parser + validator: 26 accept, 17 reject. Of the rejections, 16 are pre-existing (13{{SANDBOX_ID}}placeholders, 3 named*_rejected_*) and 1 is the new fixture this PR adds. No config regressed into an unintended rejection.The full WSLc one-shot E2E suite was run on a WSL2 host and is green, including both lifecycle fixtures.
On E2E coverage, precisely. One rejection is now asserted positively end-to-end:
wslc_destroy_on_exit_false_rejected.jsonruns in the nightly WSLc suite and requires exit-1plus the rejection message, so the payload provably never runs. The other three are not — no fixture supplies auiblock, afirewallenforcement mode, orpreservePolicy. What the config corpus covers for those is the inverse and, for this change, the higher risk: that none of the existing configs regressed into an over-rejection. The positive path is covered by unit tests instead, including the near-miss acceptances (capabilities,allowLocalNetwork: false, absentui) that a gate flipping between value- and presence-based would break. Positive E2E fixtures for the remaining three are better added alongside the promotion in PR 3, where these fields move to the top-level surface and the configs have to be rewritten anyway.Related Issues
📋 Issue Type
GitHub Actions runs the PR validation build automatically. The ADO pipeline
(
MXC-PR-Build) is the Azure version of the PR pipeline, kept in parity with the GitHubActions build; it runs on merge to
main, and Microsoft reviewers with write access can trigger iton a PR with
/azp run. See docs/pull-requests.md.If the
dependency-feed-checkcheck fails on a new dependency, the crate must be added tothe feed before the PR can pass. See docs/pull-requests.md
for the steps.
Microsoft Reviewers: Open in CodeFlow