fix(isolation-session): refuse ui and unsupported lifecycle rather than dropping them - #718
Conversation
|
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
This PR tightens IsolationSession policy validation and adds safeguards against silently dropped wire fields.
Changes:
- Rejects unsupported UI/lifecycle policies and malformed state-aware fields.
- Adds parser, backend, SDK, E2E, and VM test coverage.
- Adds a CI wire-mapping coverage gate and updates documentation.
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
tests/scripts/run_isolation_session_tests.ps1 |
Adds one-shot refusal tests. |
tests/scripts/run_isolation_session_state_aware_tests.ps1 |
Adds state-aware rejection tests. |
tests/configs/isolation_session_state_aware_start_rejected_process.json |
Tests non-exec process rejection. |
tests/configs/isolation_session_state_aware_provision_rejected_ui.json |
Tests provision UI rejection. |
tests/configs/isolation_session_state_aware_provision_rejected_flat_user.json |
Tests flat user rejection. |
tests/configs/isolation_session_one_shot_ui_rejected.json |
Tests one-shot UI rejection. |
tests/configs/isolation_session_one_shot_lifecycle_rejected.json |
Tests lifecycle rejection. |
src/testing/wxc_e2e_tests/tests/e2e_state_aware.rs |
Expands parser E2E coverage. |
src/testing/wxc_e2e_tests/tests/e2e_isolation_session_policy.rs |
Adds policy-refusal E2E suite. |
src/core/wxc_common/src/wire.rs |
Clarifies IsolationSession wire fields. |
src/core/wxc_common/src/state_aware_dispatch.rs |
Exposes prefix resolution internally. |
src/core/wxc_common/src/models.rs |
Tracks explicit UI presence. |
src/core/wxc_common/src/config_parser.rs |
Tightens mapping and phase validation. |
src/backends/isolation_session/common/src/state_aware.rs |
Tests UI refusal across phases. |
src/backends/isolation_session/common/src/policy.rs |
Rejects unsupported UI policy. |
src/backends/isolation_session/common/src/one_shot.rs |
Rejects unsupported lifecycle values. |
sdk/node/tests/integration/isolation-session-state-aware.test.ts |
Adds SDK runtime guards. |
sdk/node/src/generated/wire.ts |
Regenerates wire documentation. |
scripts/versioning/package.json |
Registers mapping checks. |
scripts/versioning/check-wire-mapping-coverage.test.js |
Self-tests the mapping gate. |
scripts/versioning/check-wire-mapping-coverage.js |
Adds wire-field coverage analysis. |
schemas/dev/mxc-config.schema.0.8.0-dev.json |
Regenerates schema descriptions. |
docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md |
Clarifies lifecycle contract. |
docs/schema.md |
Documents UI and backend behavior. |
docs/isolation-session/state-aware-rust.md |
Expands the policy honor matrix. |
docs/isolation-session/oneshot.md |
Corrects one-shot behavior documentation. |
.github/workflows/Versioning.Checks.Job.yml |
Runs new checks in CI. |
.github/copilot-instructions.md |
Records architecture and validation rules. |
6943f70 to
ada5b4c
Compare
ui and unsupported lifecycle; close the dropped-field classThere was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (3)
docs/isolation-session/state-aware-rust.md:221
- This now contradicts both the honor-matrix rows above and the parser behavior.
convert_wire_state_awaremakesprocessoptional outside exec, butconvert_wire_configstill accepts and maps a present block; the non-exec backend methods simply do not consume those fields. Document this as accepted-and-ignored rather than rejected.
rejected at non-exec state-aware phases (the parser refuses a `process`
section on any phase other than exec).
docs/isolation-session/oneshot.md:253
- This payload is not rejected on one-shot. The permissive
experimental.isolation_sessionwire type acceptsprovision/start, while one-shot conversion reads only the flatuserfield, so these nested blocks are silently ignored. This also conflicts with the matrix instate-aware-rust.mdand the PR's stated out-of-scope behavior.
| `experimental.isolation_session.{provision,start}` | rejected — per-phase config is state-aware-only |
docs/schema.md:68
- This otherwise unchanged example line now contains an embedded carriage return before the comma, producing mixed line endings and potentially splitting the rendered code sample. Remove the stray character.
"capabilities": ["internetClient"]
,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/backends/isolation_session/common/src/one_shot.rs:43
- This reason is inaccurate: every accepted one-shot request must carry the canonical network policy, so the backend does not reject network policy outright. The relevant distinction is that it installs no persistent filesystem or network enforcement to retain; state that instead so callers are not told their required network acknowledgment was rejected.
"lifecycle.preservePolicy=true is not supported by the isolation session backend; \
it rejects filesystem and network policy outright, so there is none to preserve",
| /// `network_specified` closes for the network policy. Runs after the filesystem | ||
| /// check so a filesystem rejection keeps precedence. | ||
| fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), IsolationSessionError> { | ||
| if request.policy.ui_specified { |
There was a problem hiding this comment.
[High · correctness] This refuses every one-shot request built through either SDK
ui_specified is set from cfg.ui.is_some(), which is correct for the wire. The problem
is upstream: both SDKs emit a ui block unconditionally, even when the caller's
SandboxPolicy has no UI policy at all.
src/core/mxc_engine/src/policy.rs:668-671 — inside the base json! literal, not behind
any if let Some(ui):
"ui": {
"disable": !policy.ui.as_ref().map(|u| u.allow_windows).unwrap_or(false),
"clipboard": policy.ui.as_ref().map(|u| u.clipboard).unwrap_or_default().wire(),
"injection": policy.ui.as_ref().map(|u| u.allow_input_injection).unwrap_or(false),
},sdk/node/src/sandbox.ts:268-272 does the same:
config.ui = {
disable: !(policy.ui?.allowWindows ?? false),
clipboard: policy.ui?.clipboard ?? "none",
injection: policy.ui?.allowInputInjection ?? false,
};So cfg.ui.is_some() is true for every SDK-built one-shot request, ui_specified
is true, and this check refuses it — including the plainest possible call with no UI
policy specified. The state-aware path escapes only because provisionSandbox builds
its own envelope without going through createConfigFromPolicy.
Reproduction — drop this into src/core/mxc_engine/src/policy.rs and run
cargo test -p mxc_engine test_isolation_session_via_mxc_engine:
#[test]
fn test_isolation_session_via_mxc_engine() {
let policy = crate::SandboxPolicy {
version: "0.6.0-alpha".to_string(),
timeout_ms: None,
network: None,
filesystem: None,
ui: None,
};
let req = crate::policy::build_request(&policy, None).unwrap();
assert!(!req.inner.policy.ui_specified, "UI should not be specified");
}It fails on this branch: panicked at ... UI should not be specified. (I ran it against
ada5b4cc; it is the check above that then rejects.)
The PR's own tests do not catch this because they build wire JSON directly rather than
going through the SDK policy builder, so the gap sits exactly between the two layers that
are each individually tested.
Two ways to fix, and I would suggest the second:
- Make both SDKs omit
"ui"when the caller'sSandboxPolicyhas none. Correct, but it
is a behavioural change in a cross-backend code path, and any other producer that
emits a defaultuiblock hits this again. - Accept the canonical "no restriction requested" shape here, mirroring what
validate_provision_network_policyalready does for the canonical network allow. That
keeps the refusal meaningful (a caller who genuinely asks for a UI restriction is still
refused) while letting a default-valued block through. It also matches the precedent
set immediately below in this same file, so the two policy checks stay symmetrical.
Whichever you pick, worth adding a regression test that goes through build_request /
createConfigFromPolicy rather than raw JSON — that is the seam this slipped through.
One related question on intent: as written this also refuses ui: { allowWindows: true },
i.e. a caller explicitly asking for no restriction. If that is deliberate, a line in the
doc comment saying so would help; if not, option 2 handles it.
There was a problem hiding this comment.
Thank you for the reproduction — running it verbatim is what made this quick to settle, and the observation underneath it is correct. The conclusion doesn't hold, though, and I want to lay out the evidence rather than just assert that.
Your repro reports two things. I added a print for the second:
ui_specified = true <- exactly as you said
containment = ProcessContainer
ui_specified really is true for every request built through build_wire_config — that block is unconditional, as you quoted. But the request is ProcessContainer, not IsolationSession, so reject_ui_policy is never invoked. Neither SDK can select this backend:
- Rust —
SandboxPolicyhas no containment field at all (version,filesystem,network,ui,timeout_ms).apply_backendhardcodesconfig["containment"] = json!("process"), with a#[cfg(target_os = "macos")]override to"seatbelt". There is no iso branch and no caller-facing way to ask for one. - Node —
createConfigFromPolicysetswslc/bubblewrap/seatbelt/microvm/lxc/process, and otherwise throwsContainment type '...' is not yet supported.Noisolation_sessionbranch.
So ui_specified: true on an SDK-built request is inert: it reaches ProcessContainer, which honors ui. IsolationSession one-shot is reachable only from hand-written JSON, where a ui block exists only if its author wrote one.
On your option 2 — I think it's unsound, and the reason is worth recording. With SandboxPolicy.ui = None, the block that gets emitted is disable:true, clipboard:none, injection:false — full lockdown. Accepting that as a canonical "no restriction requested" shape would mean accepting precisely the assertion this backend cannot honor. It isn't symmetric with the network gate either: the canonical network acknowledgment is a true statement about the container (the network really is unrestricted), whereas there is no true ui statement available here — disable:false asserts a GUI the user can drive, and every clipboard value asserts a relationship to the user's clipboard the sandbox cannot reach. Only injection:false is honest, and it can't be supplied alone. That reasoning is now written up in docs/isolation-session/state-aware-rust.md.
If iso ever does become SDK-reachable, your option 1 is the right fix — omit ui when the caller's policy has none.
On your last question — yes, refusing ui: { allowWindows: true } is deliberate. It maps to disable: false, which asserts the sandbox may drive a GUI the user can see; in a separate session the windows are real but unreachable and invisible. The docs now say so explicitly.
What I'm not doing, and why. The latent risk you've identified is real: the day someone adds an iso branch to either SDK, this becomes a live bug with no guard. I'd normally take your suggestion of a build_request-level regression test — but the invariant to pin lives in mxc_engine, a crate this PR doesn't touch, and both the unconditional ui emission and the missing iso branch are pre-existing. This PR has already been rescoped twice to keep it off cross-backend code, so I've recorded it as a tracked follow-up instead. The current change provably alters nothing on that path: the validator is unreachable from it either way.
| /// Cross-platform UI policy. | ||
| pub ui: UiPolicy, | ||
| /// Whether the caller supplied a `ui` block on the wire (any field | ||
| /// present), captured at parse time. The twin of `network_specified`, and | ||
| /// necessary for the same reason: `UiPolicy::default()` is full lockdown, | ||
| /// so an absent `ui` and an explicitly-supplied lockdown `ui` are | ||
| /// indistinguishable from the other fields here. Used by backends (e.g. | ||
| /// IsolationSession) that have no UI-restriction primitive and must refuse | ||
| /// a UI policy rather than accept and drop it. Parse-derived, never on the | ||
| /// wire. | ||
| #[serde(skip)] | ||
| pub ui_specified: bool, |
There was a problem hiding this comment.
[Medium · cross-platform-parity] The flag is cross-platform; the fix is Windows-only
The doc comment here is genuinely good — it explains why a presence bit is needed
better than most such comments do, and the network_specified symmetry is the right
model.
The observation is about blast radius rather than this code: ui_specified lands in the
shared ContainerPolicy, but only IsolationSession reads it. LXC and Bubblewrap neither
honour policy.ui nor refuse it, so on Linux the exact defect this PR fixes — a caller
supplies a ui policy, believes a security control is applied, and silently receives
none of it — is still present. Same for Seatbelt on macOS.
That is a reasonable scope call for one PR and I am not asking you to widen it here. Two
small things that would keep it from being forgotten:
- A tracking issue for the Linux/macOS backends, referenced from this doc comment, so the
next person reading the field knows the story is incomplete rather than assuming the
flag is universally honoured. - One line in the comment noting it is currently consumed only by IsolationSession —
otherwise the phrase "Used by backends (e.g. IsolationSession)" reads as though several
already do.
Worth deciding explicitly, because "supported everywhere except silently ignored on two
platforms" is the failure mode this PR exists to eliminate.
There was a problem hiding this comment.
Agreed, and the "e.g." was doing misleading work — it implies several backends consume this when exactly one does. Fixed in 878af37: the comment now states plainly that it's consumed only by IsolationSession today, names the backends that accept-and-ignore policy.ui (LXC, Bubblewrap, Seatbelt, Windows Sandbox), and spells out the consequence — the flag being set does not mean a UI policy was honored anywhere, only that the caller supplied one.
On the wider gap — you're right that on Linux and macOS the exact defect this PR fixes is still present, and I agree it shouldn't be silently forgotten. It's tracked as a follow-up alongside the Windows Sandbox case, which has the same shape (and whose row in the cross-backend design doc currently claims it rejects ui, which is wrong — also tracked).
I'd push back gently on one framing, though: "supported everywhere except silently ignored on two platforms" isn't quite the state. policy.ui was never honored by those backends before this PR either — this change doesn't extend a guarantee unevenly, it makes one backend stop pretending. The unevenness is pre-existing and this PR narrows it by one.
| it('backend refuses a provision that supplies a ui policy', async () => { | ||
| await assert.rejects( | ||
| () => provisionUntyped( | ||
| 'isolation_session', | ||
| { | ||
| network: { defaultPolicy: 'allow', allowLocalNetwork: true }, | ||
| ui: { disable: true }, | ||
| }, | ||
| { experimental: true }, | ||
| ), | ||
| (err: unknown) => err instanceof MxcError && err.code === 'policy_validation', | ||
| ); | ||
| }); |
There was a problem hiding this comment.
[Medium · testability] These refusal tests do not need a host, but are gated as if they do
The comment a few lines above says it explicitly — "Validation runs before the OS service
is touched, so nothing is provisioned and no cleanup is needed" — and that is exactly
right: every refusal in this PR happens in a validate_* hook before any OS-side call.
But these it blocks sit inside the describe that carries { skip: skipReason }, where
skipReason resolves via sandboxSkipReason / probeStateAwareRuntime. On any CI agent
without an isolation-capable host, the whole suite skips — including these, which had no
need of one. The net effect is that the SDK-facing half of this PR's central behaviour has
no automated gate on ordinary runs, which is a shame given the tests themselves are well
targeted (the presence-vs-value case on line 235 in particular is the right test).
Suggested: split the host-independent refusal tests into their own describe with no
skip condition, and leave the genuine lifecycle tests behind the probe. Something like:
describe('IsolationSession policy refusals (no host required)', () => {
it('backend refuses a provision that supplies a ui policy', async () => { ... });
it('backend refuses a lockdown-equivalent ui policy too (presence, not value)', async () => { ... });
});They still need wxc-exec built with --features isolation_session, so if the default
SDK integration job does not build with it, that is the other half of making this run.
There was a problem hiding this comment.
The observation is correct and the goal is right, but I'd like to defer this, with evidence that nothing is currently ungated.
These refusals do have an automated CI gate today — src/testing/wxc_e2e_tests/tests/e2e_isolation_session_policy.rs asserts the same ui refusals on both surfaces, needs no isolation-capable host for exactly the reason you quoted, and runs in the normal cargo test path. Build.Windows.Job.yml builds with --features "hyperlight isolation_session microvm wslc", so it executes on every PR. The Node skip is therefore a redundancy gap, not a coverage gap.
Two scope reasons for not restructuring here. The describe predates this PR and already contains three network refusal tests from #682 with the identical property; my ui tests followed the file's established pattern. Splitting it means relocating those too, which reaches into another PR's tests. And as you note yourself, the tests still need wxc-exec built with --features isolation_session — so the describe split alone doesn't achieve the goal; the SDK integration job would need a build change as well. That's a CI change I'd rather make deliberately than as a rider.
Worth flagging one tension for whoever picks this up: this and the proportionality comment on e2e_isolation_session_policy.rs point in opposite directions — un-skip more here, drop layers there. I'd want both resolved together rather than separately.
| #[test] | ||
| fn one_shot_refuses_ui_policy() { | ||
| if !cached_has_wxc_exe() { | ||
| return; | ||
| } | ||
|
|
||
| // The isolation session is a separate OS session, which isolates the host's | ||
| // UI from the contained code but does not deny it UI capabilities — window | ||
| // creation, GDI, and the session's own clipboard all work inside it. A `ui` | ||
| // policy therefore cannot be honored and must not be silently accepted. | ||
| let result = run_wxc_config( | ||
| "isolation_session_one_shot_ui_rejected.json", | ||
| &["--experimental"], | ||
| ); | ||
| if skipped_not_compiled(&result) { | ||
| return; | ||
| } | ||
| let combined = result.combined_output_with_decoded_base64(); | ||
| assert!( | ||
| combined.contains("UI policy is not supported"), | ||
| "expected a UI-policy refusal, got exit {:?}\n--- stdout ---\n{}\n--- stderr ---\n{}", | ||
| result.code, | ||
| result.stdout, | ||
| result.stderr, | ||
| ); | ||
| assert_ne!(result.code, Some(0), "non-zero exit expected on refusal"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn one_shot_refuses_destroy_on_exit_false() { | ||
| if !cached_has_wxc_exe() { | ||
| return; | ||
| } | ||
|
|
||
| // The in-proc API exposes no session-lifetime knob: one-shot always stops | ||
| // the session and removes the agent user before returning. `false` asks for | ||
| // something the backend cannot deliver. | ||
| let result = run_wxc_config( | ||
| "isolation_session_one_shot_lifecycle_rejected.json", | ||
| &["--experimental"], | ||
| ); | ||
| if skipped_not_compiled(&result) { | ||
| return; | ||
| } | ||
| let combined = result.combined_output_with_decoded_base64(); | ||
| assert!( | ||
| combined.contains("lifecycle.destroyOnExit=false"), | ||
| "expected a lifecycle refusal, got exit {:?}\n--- stdout ---\n{}\n--- stderr ---\n{}", | ||
| result.code, | ||
| result.stdout, | ||
| result.stderr, | ||
| ); | ||
| assert_ne!(result.code, Some(0), "non-zero exit expected on refusal"); | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // State-aware: refusals surface as a typed envelope on stdout. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| #[test] | ||
| fn state_aware_provision_refuses_ui_policy_with_policy_validation() { | ||
| if !cached_has_wxc_exe() { | ||
| return; | ||
| } | ||
|
|
||
| let request = json!({ | ||
| "phase": "provision", | ||
| "containment": "isolation_session", | ||
| "network": { "defaultPolicy": "allow", "allowLocalNetwork": true }, | ||
| "ui": { "disable": true } | ||
| }); | ||
| let result = run_wxc_state_aware("iso provision + ui", &request, &["--experimental"]); | ||
| let code = error_code_on_stdout(&result); | ||
| if code == "unsupported_phase" || code == "unsupported_containment" { | ||
| println!("SKIPPED: wxc-exec.exe was built without --features isolation_session"); | ||
| return; | ||
| } | ||
| assert_eq!( | ||
| code, "policy_validation", | ||
| "expected policy_validation for a supplied `ui`, got {:?}; stdout={:?}", | ||
| code, result.stdout | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn state_aware_provision_accepts_canonical_request_shape() { | ||
| if !cached_has_wxc_exe() { | ||
| return; | ||
| } | ||
|
|
||
| // Guard against over-rejection: the canonical provision shape must still | ||
| // get past validation. `--dry-run` stops before the backend provisions | ||
| // anything, so this is safe on a host with the OS-side service and on one | ||
| // without it alike. | ||
| let request = json!({ | ||
| "phase": "provision", | ||
| "containment": "isolation_session", | ||
| "network": { "defaultPolicy": "allow", "allowLocalNetwork": true } | ||
| }); | ||
| let result = run_wxc_state_aware( | ||
| "iso provision canonical (dry-run)", | ||
| &request, | ||
| &["--experimental", "--dry-run"], | ||
| ); | ||
| let stdout = result.stdout.trim(); | ||
| let parsed: Value = match serde_json::from_str(stdout) { | ||
| Ok(v) => v, | ||
| Err(_) => panic!("stdout did not parse as JSON: {stdout}"), | ||
| }; | ||
| if let Some(code) = parsed | ||
| .get("error") | ||
| .and_then(|e| e.get("code")) | ||
| .and_then(|c| c.as_str()) | ||
| { | ||
| if code == "unsupported_phase" || code == "unsupported_containment" { | ||
| println!("SKIPPED: wxc-exec.exe was built without --features isolation_session"); | ||
| return; | ||
| } | ||
| panic!("canonical provision was refused with {code}: {stdout}"); | ||
| } | ||
| assert!( | ||
| parsed.get("result").is_some(), | ||
| "expected a result envelope, got {stdout}" | ||
| ); |
There was a problem hiding this comment.
[Medium · proportionality] Four harnesses now cover the same pre-service validation
To be clear about what this file does well: it is careful work, the skip helper is honest
about why it skipped, and the over-rejection guard (asserting an absent ui is still
accepted) is the test I most wanted to see in this PR — that one earns its place.
The concern is the aggregate. The same refusals are now asserted in the backend unit
tests, in this 195-line E2E file, in the Node integration suite, and via three
tests/configs/*.json fixtures wired into two PowerShell runners. These are deterministic
validator checks that fail before any OS-side call, so the extra layers are not buying
coverage of different failure modes — they mostly re-verify the same if statements
through progressively more expensive transports, and each one is a place a future
signature change has to be updated.
Suggested: keep the unit tests plus one process-boundary case per surface (one-shot and
state-aware) to prove the refusal survives serialization and reaches the exit code, and
drop the rest. That preserves what the E2E layer uniquely proves without carrying four
parallel copies of the same matrix.
Not a blocker, and I would rather have this than too little — but worth a look before it
becomes the pattern the next policy refusal is expected to follow.
[Low · proportionality] Unrelated doc corrections are bundled in
Separately: docs/isolation-session/oneshot.md:159-166 and neighbouring hunks correct
pre-existing text about stdio/ConPTY, backend file locations, and teardown behaviour that
is unrelated to the ui / lifecycle refusals this PR is about. They look correct, and I
am glad someone is fixing them — but roughly 100 lines of unrelated doc churn sits between
a reviewer and the two-line behaviour change, which is a shame for a PR whose actual risk
is concentrated in a single if. A separate docs PR would land instantly and leave this
one easy to read.
There was a problem hiding this comment.
Fair challenge, and the maintenance-cost point is real — four places to update on a signature change is a genuine cost. I'd like to keep them, but on narrower grounds than "more is better", because you're right that they're not buying different failure modes.
What each layer uniquely proves:
- Unit — the validator logic, including precedence (filesystem -> ui -> network) and the presence-vs-value distinction. Cheapest, and where the real matrix lives.
- This E2E file — that the refusal survives serialization and reaches a process exit code with the designed
error.code. Your point stands that it re-tests the sameif, but it's the only layer that proves the wire envelope is well-formed. - Node — that
uiis reachable from plain JS despite the typed per-phaseConfigtypes excluding it. That's a different failure mode: a TypeScript-only guard would be vacuous, since the field can't be expressed in the typed API at all. - VM fixtures — that the shipped binary on the target OS refuses, catching feature-gate and packaging mistakes the host tests can't see.
That said, the over-rejection guard you singled out is the one I'd defend hardest too, and if the aggregate needs trimming later, this file's redundant positive cases are where I'd start rather than the guard.
One piece of context: three-tier coverage (Rust unit, Rust integration, Node integration) is a standing expectation on this repo, so trimming below it is a convention change rather than a local call — better made deliberately than in a policy PR.
On the bundled doc corrections raised in the same comment — accepted as a fair criticism, and I'd rather acknowledge it than argue.
The reason they're here: correcting claims that no longer matched the code was part of the stated scope — the first commit is titled exactly that, and several of those claims were about the same ui/lifecycle surface (oneshot.md listed lifecycle.destroyOnExit as "Implemented" while the code ignored it, which is the defect this PR fixes, described in prose). Splitting cleanly at that boundary is harder than it looks from the diff.
That said, the stdio/ConPTY and file-location corrections genuinely aren't that, and you're right that ~100 lines of unrelated churn between a reviewer and a two-line behaviour change is a poor trade. Extracting them now would mean a second PR plus a full re-validation (this branch gates on an isolation-capable VM run, automated plus operator-judged) for text already validated — a real cost for a change that's already reviewed. I'd rather carry the lesson forward than re-cut it at this point, but say the word if you'd prefer the split and I'll do it.
29a9428 to
9dbb4c7
Compare
oneshot.md listed lifecycle.destroyOnExit under Implemented and described it as mapped to an OS-side lifetime policy; the backend has no such knob and silently ignored the field. It also claimed the runner does not use stdin, terminate, control signals or ConPTY -- all four are used -- and carried a stale ConPTY deferral plus pre-Preview-API type names. schema.md omitted the ui section from the Full Schema example and had no ui field table at all, and stated that other backend sections are ignored when validate_single_backend_section rejects them. isolation_session and hyperlight were missing from the concrete-backends table. The cross-backend design doc described IsolationSessionProvisionMetadata as one field when it carries three, and its containerId claim did not match the parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
…an dropping them A caller could supply a schema-valid ui policy in the documented slot on the correct surface and silently receive none of it. That is the caller doing everything right and still losing a security control they believe is applied, so the backend now refuses it at every phase on both surfaces with policy_validation. The refusal is presence-based via a new ContainerPolicy::ui_specified flag, the twin of network_specified. UiPolicy::default() is full lockdown, so an explicitly-supplied lockdown ui is indistinguishable by value from an absent one; without a presence bit the backend cannot tell 'caller asked for lockdown' from 'caller said nothing'. Measured on a live session before choosing to refuse rather than treat lockdown as vacuously satisfied: window creation, GetDC/GetDeviceCaps/GetSystemMetrics and the session's own clipboard all succeed inside the session; only SendInput is denied. The session isolates the HOST's UI from contained code, but the ui fields are written as capability denial, so accepting them would assert a Win32k attack-surface reduction that is not delivered. lifecycle is refused by value on one-shot, where the defaults do match reality: the backend always stops the session and removes the agent user before returning, which is exactly destroyOnExit=true. Only destroyOnExit=false and preservePolicy=true are refused. State-aware already rejected the whole section. Tests at all three tiers: Rust unit for every phase, absent and lockdown-equivalent cases, the filesystem -> ui -> network precedence and ui_specified on both surfaces; a new e2e_isolation_session_policy.rs whose refusals all happen in validate_* before any OS-side call, so it needs no isolation-capable host and skips cleanly when the feature is off, with an over-rejection guard; and Node integration guards, since ui is reachable from plain JS even though the typed per-phase configs exclude it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
…h surfaces The state-aware design's §10.3 scopes the required honor matrix to filesystem / network / ui and to the state-aware surface only. That narrowness is why the ui and lifecycle gaps went unnoticed, so the matrix now covers every field a caller can express, on one-shot and all five state-aware phases, with per-row notes for the rows that are not a simple accept/reject. Rows that are accepted-and-ignored are documented as such rather than quietly omitted: process on non-exec state-aware phases, and mis-slotted experimental.isolation_session payloads (the flat user spelling on state-aware, a nested provision/start block on one-shot, and a block under a phase that is not the request's own). Each is a caller supplying a documented field in an undocumented position; the result is a local rather than Entra-backed sandbox, which is a capability downgrade that surfaces downstream as an auth failure. Detecting mis-slotted payloads generically is a cross-backend concern and is deliberately not solved here. §10.3's normative list and the cross-backend contract are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
…y rescope
Two honor-matrix claims described behaviour that existed only while the parser carried the generic mis-slotting rules, which were removed as out of scope. Both are now documented as accepted-and-ignored, matching what the code does:
* state-aware-rust.md said the parser refuses a non-exec 'process' section. It does not -- the dispatcher simply reads 'process' only on exec, and nothing runs at the other phases. The matrix rows were updated at rescope but this prose was missed.
* oneshot.md said a nested experimental.isolation_session.{provision,start} block is rejected on one-shot. The one-shot mapping reads only the flat 'user', so the nested blocks are ignored. The flat 'user' IS still rejected (validate_runner), so that neighbouring row stands.
Both verified by probe against the current parser rather than by inspection, along with every other rejection claim in the two documents.
Also removes a stray mid-line carriage return introduced in docs/schema.md, which split a JSON sample line from its trailing comma. The EOL check used until now compared git diff --stat against --ignore-cr-at-eol --stat, which by construction cannot see a CR that is not at end-of-line; a bare-CR scan over every changed file is clean.
Documentation only -- no code, no schema, no test changes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
…ur stale claims Review raised that an omitted ui is accepted while the schema says omission equals full lockdown, and proposed requiring an explicit acknowledgment of the real posture, mirroring the network gate. That direction is unsound: there is no truthful ui posture for this backend to acknowledge. The section states intent about the contained code's relationship to the USER's environment, and was modelled on a process/job boundary where 'the clipboard' and 'the desktop' are the user's. An isolation session is a separate OS session, so the contained code keeps its UI capabilities but cannot reach the host's. disable=true denies capabilities the session grants; disable=false promises a GUI the user can never see; every clipboard value describes a relationship to a clipboard the sandbox cannot touch. Only injection=false is honest, and it cannot be supplied alone because the other fields materialize to defaults that are false. An acknowledgment gate needs a true statement to acknowledge, so unlike network there is none available. Documented accordingly, with the field-by-field table in state-aware-rust.md and the rationale in oneshot.md. schema.md's 'omitted ui equals full lockdown' is qualified as per-backend rather than global, and names IsolationSession. ERR_UI_POLICY no longer advises removing the section as though removal were equivalent -- it states that omission is accepted but applies no restriction. Four stale claims corrected, all falsified by this PR's own retained work rather than by the removed work an earlier sweep looked for: * copilot-instructions claimed policy_validation on both surfaces; one-shot discards the typed variant and emits backend_error with the reason in the message. Documented, not changed -- threading a typed code through ScriptResponse touches every backend's one-shot path. * the honor matrix marked an absent network policy rejected on post-provision phases; validate_post_provision_policy gates on network_specified, so absent is inherited. Row split. * the matrix marked every foreign backend section rejected; a lone foreign experimental section on a non-provision phase is accepted and ignored, because those requests carry no containment to compare against. Row split, stable sections kept as rejected. * manager.rs still said lifecycle.destroyOnExit is silently ignored, which this PR made false. Also fixes the preservePolicy message (the backend requires the canonical network acknowledgment rather than rejecting network policy outright) and adds that acknowledgment to both oneshot.md examples, which were non-runnable -- verified through the real binary: both now validate, and the previous shape is refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
Review flagged that 'Used by backends (e.g. IsolationSession)' implies several backends consume this flag when exactly one does, and that the phrasing hides an incomplete story: LXC, Bubblewrap, Seatbelt and Windows Sandbox all still accept and ignore policy.ui, so the Linux and macOS instances of the defect this change fixes remain open. The comment now names IsolationSession as the only consumer today, names the backends that accept-and-ignore, and states the consequence plainly -- the flag being set does not mean a UI policy was honored anywhere, only that the caller supplied one. Comment only; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
878af37 to
6d62520
Compare
…an dropping them (#718) * docs(isolation-session): correct claims that no longer match the code oneshot.md listed lifecycle.destroyOnExit under Implemented and described it as mapped to an OS-side lifetime policy; the backend has no such knob and silently ignored the field. It also claimed the runner does not use stdin, terminate, control signals or ConPTY -- all four are used -- and carried a stale ConPTY deferral plus pre-Preview-API type names. schema.md omitted the ui section from the Full Schema example and had no ui field table at all, and stated that other backend sections are ignored when validate_single_backend_section rejects them. isolation_session and hyperlight were missing from the concrete-backends table. The cross-backend design doc described IsolationSessionProvisionMetadata as one field when it carries three, and its containerId claim did not match the parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * fix(isolation-session): refuse ui and unsupported lifecycle rather than dropping them A caller could supply a schema-valid ui policy in the documented slot on the correct surface and silently receive none of it. That is the caller doing everything right and still losing a security control they believe is applied, so the backend now refuses it at every phase on both surfaces with policy_validation. The refusal is presence-based via a new ContainerPolicy::ui_specified flag, the twin of network_specified. UiPolicy::default() is full lockdown, so an explicitly-supplied lockdown ui is indistinguishable by value from an absent one; without a presence bit the backend cannot tell 'caller asked for lockdown' from 'caller said nothing'. Measured on a live session before choosing to refuse rather than treat lockdown as vacuously satisfied: window creation, GetDC/GetDeviceCaps/GetSystemMetrics and the session's own clipboard all succeed inside the session; only SendInput is denied. The session isolates the HOST's UI from contained code, but the ui fields are written as capability denial, so accepting them would assert a Win32k attack-surface reduction that is not delivered. lifecycle is refused by value on one-shot, where the defaults do match reality: the backend always stops the session and removes the agent user before returning, which is exactly destroyOnExit=true. Only destroyOnExit=false and preservePolicy=true are refused. State-aware already rejected the whole section. Tests at all three tiers: Rust unit for every phase, absent and lockdown-equivalent cases, the filesystem -> ui -> network precedence and ui_specified on both surfaces; a new e2e_isolation_session_policy.rs whose refusals all happen in validate_* before any OS-side call, so it needs no isolation-capable host and skips cleanly when the feature is off, with an over-rejection guard; and Node integration guards, since ui is reachable from plain JS even though the typed per-phase configs exclude it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): publish the full policy honor matrix for both surfaces The state-aware design's §10.3 scopes the required honor matrix to filesystem / network / ui and to the state-aware surface only. That narrowness is why the ui and lifecycle gaps went unnoticed, so the matrix now covers every field a caller can express, on one-shot and all five state-aware phases, with per-row notes for the rows that are not a simple accept/reject. Rows that are accepted-and-ignored are documented as such rather than quietly omitted: process on non-exec state-aware phases, and mis-slotted experimental.isolation_session payloads (the flat user spelling on state-aware, a nested provision/start block on one-shot, and a block under a phase that is not the request's own). Each is a caller supplying a documented field in an undocumented position; the result is a local rather than Entra-backed sandbox, which is a capability downgrade that surfaces downstream as an auth failure. Detecting mis-slotted payloads generically is a cross-backend concern and is deliberately not solved here. §10.3's normative list and the cross-backend contract are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): correct three claims left stale by the policy rescope Two honor-matrix claims described behaviour that existed only while the parser carried the generic mis-slotting rules, which were removed as out of scope. Both are now documented as accepted-and-ignored, matching what the code does: * state-aware-rust.md said the parser refuses a non-exec 'process' section. It does not -- the dispatcher simply reads 'process' only on exec, and nothing runs at the other phases. The matrix rows were updated at rescope but this prose was missed. * oneshot.md said a nested experimental.isolation_session.{provision,start} block is rejected on one-shot. The one-shot mapping reads only the flat 'user', so the nested blocks are ignored. The flat 'user' IS still rejected (validate_runner), so that neighbouring row stands. Both verified by probe against the current parser rather than by inspection, along with every other rejection claim in the two documents. Also removes a stray mid-line carriage return introduced in docs/schema.md, which split a JSON sample line from its trailing comma. The EOL check used until now compared git diff --stat against --ignore-cr-at-eol --stat, which by construction cannot see a CR that is not at end-of-line; a bare-CR scan over every changed file is clean. Documentation only -- no code, no schema, no test changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): state the ui contract plainly and correct four stale claims Review raised that an omitted ui is accepted while the schema says omission equals full lockdown, and proposed requiring an explicit acknowledgment of the real posture, mirroring the network gate. That direction is unsound: there is no truthful ui posture for this backend to acknowledge. The section states intent about the contained code's relationship to the USER's environment, and was modelled on a process/job boundary where 'the clipboard' and 'the desktop' are the user's. An isolation session is a separate OS session, so the contained code keeps its UI capabilities but cannot reach the host's. disable=true denies capabilities the session grants; disable=false promises a GUI the user can never see; every clipboard value describes a relationship to a clipboard the sandbox cannot touch. Only injection=false is honest, and it cannot be supplied alone because the other fields materialize to defaults that are false. An acknowledgment gate needs a true statement to acknowledge, so unlike network there is none available. Documented accordingly, with the field-by-field table in state-aware-rust.md and the rationale in oneshot.md. schema.md's 'omitted ui equals full lockdown' is qualified as per-backend rather than global, and names IsolationSession. ERR_UI_POLICY no longer advises removing the section as though removal were equivalent -- it states that omission is accepted but applies no restriction. Four stale claims corrected, all falsified by this PR's own retained work rather than by the removed work an earlier sweep looked for: * copilot-instructions claimed policy_validation on both surfaces; one-shot discards the typed variant and emits backend_error with the reason in the message. Documented, not changed -- threading a typed code through ScriptResponse touches every backend's one-shot path. * the honor matrix marked an absent network policy rejected on post-provision phases; validate_post_provision_policy gates on network_specified, so absent is inherited. Row split. * the matrix marked every foreign backend section rejected; a lone foreign experimental section on a non-provision phase is accepted and ignored, because those requests carry no containment to compare against. Row split, stable sections kept as rejected. * manager.rs still said lifecycle.destroyOnExit is silently ignored, which this PR made false. Also fixes the preservePolicy message (the backend requires the canonical network acknowledgment rather than rejecting network policy outright) and adds that acknowledgment to both oneshot.md examples, which were non-runnable -- verified through the real binary: both now validate, and the previous shape is refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(models): scope the ui_specified comment to its one consumer Review flagged that 'Used by backends (e.g. IsolationSession)' implies several backends consume this flag when exactly one does, and that the phrasing hides an incomplete story: LXC, Bubblewrap, Seatbelt and Windows Sandbox all still accept and ignore policy.ui, so the Linux and macOS instances of the defect this change fixes remain open. The comment now names IsolationSession as the only consumer today, names the backends that accept-and-ignore, and states the consequence plainly -- the flag being set does not mean a UI policy was honored anywhere, only that the caller supplied one. Comment only; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c --------- Co-authored-by: adpa-ms <> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
…an dropping them (#718) * docs(isolation-session): correct claims that no longer match the code oneshot.md listed lifecycle.destroyOnExit under Implemented and described it as mapped to an OS-side lifetime policy; the backend has no such knob and silently ignored the field. It also claimed the runner does not use stdin, terminate, control signals or ConPTY -- all four are used -- and carried a stale ConPTY deferral plus pre-Preview-API type names. schema.md omitted the ui section from the Full Schema example and had no ui field table at all, and stated that other backend sections are ignored when validate_single_backend_section rejects them. isolation_session and hyperlight were missing from the concrete-backends table. The cross-backend design doc described IsolationSessionProvisionMetadata as one field when it carries three, and its containerId claim did not match the parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * fix(isolation-session): refuse ui and unsupported lifecycle rather than dropping them A caller could supply a schema-valid ui policy in the documented slot on the correct surface and silently receive none of it. That is the caller doing everything right and still losing a security control they believe is applied, so the backend now refuses it at every phase on both surfaces with policy_validation. The refusal is presence-based via a new ContainerPolicy::ui_specified flag, the twin of network_specified. UiPolicy::default() is full lockdown, so an explicitly-supplied lockdown ui is indistinguishable by value from an absent one; without a presence bit the backend cannot tell 'caller asked for lockdown' from 'caller said nothing'. Measured on a live session before choosing to refuse rather than treat lockdown as vacuously satisfied: window creation, GetDC/GetDeviceCaps/GetSystemMetrics and the session's own clipboard all succeed inside the session; only SendInput is denied. The session isolates the HOST's UI from contained code, but the ui fields are written as capability denial, so accepting them would assert a Win32k attack-surface reduction that is not delivered. lifecycle is refused by value on one-shot, where the defaults do match reality: the backend always stops the session and removes the agent user before returning, which is exactly destroyOnExit=true. Only destroyOnExit=false and preservePolicy=true are refused. State-aware already rejected the whole section. Tests at all three tiers: Rust unit for every phase, absent and lockdown-equivalent cases, the filesystem -> ui -> network precedence and ui_specified on both surfaces; a new e2e_isolation_session_policy.rs whose refusals all happen in validate_* before any OS-side call, so it needs no isolation-capable host and skips cleanly when the feature is off, with an over-rejection guard; and Node integration guards, since ui is reachable from plain JS even though the typed per-phase configs exclude it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): publish the full policy honor matrix for both surfaces The state-aware design's §10.3 scopes the required honor matrix to filesystem / network / ui and to the state-aware surface only. That narrowness is why the ui and lifecycle gaps went unnoticed, so the matrix now covers every field a caller can express, on one-shot and all five state-aware phases, with per-row notes for the rows that are not a simple accept/reject. Rows that are accepted-and-ignored are documented as such rather than quietly omitted: process on non-exec state-aware phases, and mis-slotted experimental.isolation_session payloads (the flat user spelling on state-aware, a nested provision/start block on one-shot, and a block under a phase that is not the request's own). Each is a caller supplying a documented field in an undocumented position; the result is a local rather than Entra-backed sandbox, which is a capability downgrade that surfaces downstream as an auth failure. Detecting mis-slotted payloads generically is a cross-backend concern and is deliberately not solved here. §10.3's normative list and the cross-backend contract are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): correct three claims left stale by the policy rescope Two honor-matrix claims described behaviour that existed only while the parser carried the generic mis-slotting rules, which were removed as out of scope. Both are now documented as accepted-and-ignored, matching what the code does: * state-aware-rust.md said the parser refuses a non-exec 'process' section. It does not -- the dispatcher simply reads 'process' only on exec, and nothing runs at the other phases. The matrix rows were updated at rescope but this prose was missed. * oneshot.md said a nested experimental.isolation_session.{provision,start} block is rejected on one-shot. The one-shot mapping reads only the flat 'user', so the nested blocks are ignored. The flat 'user' IS still rejected (validate_runner), so that neighbouring row stands. Both verified by probe against the current parser rather than by inspection, along with every other rejection claim in the two documents. Also removes a stray mid-line carriage return introduced in docs/schema.md, which split a JSON sample line from its trailing comma. The EOL check used until now compared git diff --stat against --ignore-cr-at-eol --stat, which by construction cannot see a CR that is not at end-of-line; a bare-CR scan over every changed file is clean. Documentation only -- no code, no schema, no test changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): state the ui contract plainly and correct four stale claims Review raised that an omitted ui is accepted while the schema says omission equals full lockdown, and proposed requiring an explicit acknowledgment of the real posture, mirroring the network gate. That direction is unsound: there is no truthful ui posture for this backend to acknowledge. The section states intent about the contained code's relationship to the USER's environment, and was modelled on a process/job boundary where 'the clipboard' and 'the desktop' are the user's. An isolation session is a separate OS session, so the contained code keeps its UI capabilities but cannot reach the host's. disable=true denies capabilities the session grants; disable=false promises a GUI the user can never see; every clipboard value describes a relationship to a clipboard the sandbox cannot touch. Only injection=false is honest, and it cannot be supplied alone because the other fields materialize to defaults that are false. An acknowledgment gate needs a true statement to acknowledge, so unlike network there is none available. Documented accordingly, with the field-by-field table in state-aware-rust.md and the rationale in oneshot.md. schema.md's 'omitted ui equals full lockdown' is qualified as per-backend rather than global, and names IsolationSession. ERR_UI_POLICY no longer advises removing the section as though removal were equivalent -- it states that omission is accepted but applies no restriction. Four stale claims corrected, all falsified by this PR's own retained work rather than by the removed work an earlier sweep looked for: * copilot-instructions claimed policy_validation on both surfaces; one-shot discards the typed variant and emits backend_error with the reason in the message. Documented, not changed -- threading a typed code through ScriptResponse touches every backend's one-shot path. * the honor matrix marked an absent network policy rejected on post-provision phases; validate_post_provision_policy gates on network_specified, so absent is inherited. Row split. * the matrix marked every foreign backend section rejected; a lone foreign experimental section on a non-provision phase is accepted and ignored, because those requests carry no containment to compare against. Row split, stable sections kept as rejected. * manager.rs still said lifecycle.destroyOnExit is silently ignored, which this PR made false. Also fixes the preservePolicy message (the backend requires the canonical network acknowledgment rather than rejecting network policy outright) and adds that acknowledgment to both oneshot.md examples, which were non-runnable -- verified through the real binary: both now validate, and the previous shape is refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(models): scope the ui_specified comment to its one consumer Review flagged that 'Used by backends (e.g. IsolationSession)' implies several backends consume this flag when exactly one does, and that the phrasing hides an incomplete story: LXC, Bubblewrap, Seatbelt and Windows Sandbox all still accept and ignore policy.ui, so the Linux and macOS instances of the defect this change fixes remain open. The comment now names IsolationSession as the only consumer today, names the backends that accept-and-ignore, and states the consequence plainly -- the flag being set does not mean a UI policy was honored anywhere, only that the caller supplied one. Comment only; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c --------- Co-authored-by: adpa-ms <> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
…an dropping them (#718) * docs(isolation-session): correct claims that no longer match the code oneshot.md listed lifecycle.destroyOnExit under Implemented and described it as mapped to an OS-side lifetime policy; the backend has no such knob and silently ignored the field. It also claimed the runner does not use stdin, terminate, control signals or ConPTY -- all four are used -- and carried a stale ConPTY deferral plus pre-Preview-API type names. schema.md omitted the ui section from the Full Schema example and had no ui field table at all, and stated that other backend sections are ignored when validate_single_backend_section rejects them. isolation_session and hyperlight were missing from the concrete-backends table. The cross-backend design doc described IsolationSessionProvisionMetadata as one field when it carries three, and its containerId claim did not match the parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * fix(isolation-session): refuse ui and unsupported lifecycle rather than dropping them A caller could supply a schema-valid ui policy in the documented slot on the correct surface and silently receive none of it. That is the caller doing everything right and still losing a security control they believe is applied, so the backend now refuses it at every phase on both surfaces with policy_validation. The refusal is presence-based via a new ContainerPolicy::ui_specified flag, the twin of network_specified. UiPolicy::default() is full lockdown, so an explicitly-supplied lockdown ui is indistinguishable by value from an absent one; without a presence bit the backend cannot tell 'caller asked for lockdown' from 'caller said nothing'. Measured on a live session before choosing to refuse rather than treat lockdown as vacuously satisfied: window creation, GetDC/GetDeviceCaps/GetSystemMetrics and the session's own clipboard all succeed inside the session; only SendInput is denied. The session isolates the HOST's UI from contained code, but the ui fields are written as capability denial, so accepting them would assert a Win32k attack-surface reduction that is not delivered. lifecycle is refused by value on one-shot, where the defaults do match reality: the backend always stops the session and removes the agent user before returning, which is exactly destroyOnExit=true. Only destroyOnExit=false and preservePolicy=true are refused. State-aware already rejected the whole section. Tests at all three tiers: Rust unit for every phase, absent and lockdown-equivalent cases, the filesystem -> ui -> network precedence and ui_specified on both surfaces; a new e2e_isolation_session_policy.rs whose refusals all happen in validate_* before any OS-side call, so it needs no isolation-capable host and skips cleanly when the feature is off, with an over-rejection guard; and Node integration guards, since ui is reachable from plain JS even though the typed per-phase configs exclude it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): publish the full policy honor matrix for both surfaces The state-aware design's §10.3 scopes the required honor matrix to filesystem / network / ui and to the state-aware surface only. That narrowness is why the ui and lifecycle gaps went unnoticed, so the matrix now covers every field a caller can express, on one-shot and all five state-aware phases, with per-row notes for the rows that are not a simple accept/reject. Rows that are accepted-and-ignored are documented as such rather than quietly omitted: process on non-exec state-aware phases, and mis-slotted experimental.isolation_session payloads (the flat user spelling on state-aware, a nested provision/start block on one-shot, and a block under a phase that is not the request's own). Each is a caller supplying a documented field in an undocumented position; the result is a local rather than Entra-backed sandbox, which is a capability downgrade that surfaces downstream as an auth failure. Detecting mis-slotted payloads generically is a cross-backend concern and is deliberately not solved here. §10.3's normative list and the cross-backend contract are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): correct three claims left stale by the policy rescope Two honor-matrix claims described behaviour that existed only while the parser carried the generic mis-slotting rules, which were removed as out of scope. Both are now documented as accepted-and-ignored, matching what the code does: * state-aware-rust.md said the parser refuses a non-exec 'process' section. It does not -- the dispatcher simply reads 'process' only on exec, and nothing runs at the other phases. The matrix rows were updated at rescope but this prose was missed. * oneshot.md said a nested experimental.isolation_session.{provision,start} block is rejected on one-shot. The one-shot mapping reads only the flat 'user', so the nested blocks are ignored. The flat 'user' IS still rejected (validate_runner), so that neighbouring row stands. Both verified by probe against the current parser rather than by inspection, along with every other rejection claim in the two documents. Also removes a stray mid-line carriage return introduced in docs/schema.md, which split a JSON sample line from its trailing comma. The EOL check used until now compared git diff --stat against --ignore-cr-at-eol --stat, which by construction cannot see a CR that is not at end-of-line; a bare-CR scan over every changed file is clean. Documentation only -- no code, no schema, no test changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): state the ui contract plainly and correct four stale claims Review raised that an omitted ui is accepted while the schema says omission equals full lockdown, and proposed requiring an explicit acknowledgment of the real posture, mirroring the network gate. That direction is unsound: there is no truthful ui posture for this backend to acknowledge. The section states intent about the contained code's relationship to the USER's environment, and was modelled on a process/job boundary where 'the clipboard' and 'the desktop' are the user's. An isolation session is a separate OS session, so the contained code keeps its UI capabilities but cannot reach the host's. disable=true denies capabilities the session grants; disable=false promises a GUI the user can never see; every clipboard value describes a relationship to a clipboard the sandbox cannot touch. Only injection=false is honest, and it cannot be supplied alone because the other fields materialize to defaults that are false. An acknowledgment gate needs a true statement to acknowledge, so unlike network there is none available. Documented accordingly, with the field-by-field table in state-aware-rust.md and the rationale in oneshot.md. schema.md's 'omitted ui equals full lockdown' is qualified as per-backend rather than global, and names IsolationSession. ERR_UI_POLICY no longer advises removing the section as though removal were equivalent -- it states that omission is accepted but applies no restriction. Four stale claims corrected, all falsified by this PR's own retained work rather than by the removed work an earlier sweep looked for: * copilot-instructions claimed policy_validation on both surfaces; one-shot discards the typed variant and emits backend_error with the reason in the message. Documented, not changed -- threading a typed code through ScriptResponse touches every backend's one-shot path. * the honor matrix marked an absent network policy rejected on post-provision phases; validate_post_provision_policy gates on network_specified, so absent is inherited. Row split. * the matrix marked every foreign backend section rejected; a lone foreign experimental section on a non-provision phase is accepted and ignored, because those requests carry no containment to compare against. Row split, stable sections kept as rejected. * manager.rs still said lifecycle.destroyOnExit is silently ignored, which this PR made false. Also fixes the preservePolicy message (the backend requires the canonical network acknowledgment rather than rejecting network policy outright) and adds that acknowledgment to both oneshot.md examples, which were non-runnable -- verified through the real binary: both now validate, and the previous shape is refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(models): scope the ui_specified comment to its one consumer Review flagged that 'Used by backends (e.g. IsolationSession)' implies several backends consume this flag when exactly one does, and that the phrasing hides an incomplete story: LXC, Bubblewrap, Seatbelt and Windows Sandbox all still accept and ignore policy.ui, so the Linux and macOS instances of the defect this change fixes remain open. The comment now names IsolationSession as the only consumer today, names the backends that accept-and-ignore, and states the consequence plainly -- the flag being set does not mean a UI policy was honored anywhere, only that the caller supplied one. Comment only; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c --------- Co-authored-by: adpa-ms <> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
…an dropping them (#718) * docs(isolation-session): correct claims that no longer match the code oneshot.md listed lifecycle.destroyOnExit under Implemented and described it as mapped to an OS-side lifetime policy; the backend has no such knob and silently ignored the field. It also claimed the runner does not use stdin, terminate, control signals or ConPTY -- all four are used -- and carried a stale ConPTY deferral plus pre-Preview-API type names. schema.md omitted the ui section from the Full Schema example and had no ui field table at all, and stated that other backend sections are ignored when validate_single_backend_section rejects them. isolation_session and hyperlight were missing from the concrete-backends table. The cross-backend design doc described IsolationSessionProvisionMetadata as one field when it carries three, and its containerId claim did not match the parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * fix(isolation-session): refuse ui and unsupported lifecycle rather than dropping them A caller could supply a schema-valid ui policy in the documented slot on the correct surface and silently receive none of it. That is the caller doing everything right and still losing a security control they believe is applied, so the backend now refuses it at every phase on both surfaces with policy_validation. The refusal is presence-based via a new ContainerPolicy::ui_specified flag, the twin of network_specified. UiPolicy::default() is full lockdown, so an explicitly-supplied lockdown ui is indistinguishable by value from an absent one; without a presence bit the backend cannot tell 'caller asked for lockdown' from 'caller said nothing'. Measured on a live session before choosing to refuse rather than treat lockdown as vacuously satisfied: window creation, GetDC/GetDeviceCaps/GetSystemMetrics and the session's own clipboard all succeed inside the session; only SendInput is denied. The session isolates the HOST's UI from contained code, but the ui fields are written as capability denial, so accepting them would assert a Win32k attack-surface reduction that is not delivered. lifecycle is refused by value on one-shot, where the defaults do match reality: the backend always stops the session and removes the agent user before returning, which is exactly destroyOnExit=true. Only destroyOnExit=false and preservePolicy=true are refused. State-aware already rejected the whole section. Tests at all three tiers: Rust unit for every phase, absent and lockdown-equivalent cases, the filesystem -> ui -> network precedence and ui_specified on both surfaces; a new e2e_isolation_session_policy.rs whose refusals all happen in validate_* before any OS-side call, so it needs no isolation-capable host and skips cleanly when the feature is off, with an over-rejection guard; and Node integration guards, since ui is reachable from plain JS even though the typed per-phase configs exclude it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): publish the full policy honor matrix for both surfaces The state-aware design's §10.3 scopes the required honor matrix to filesystem / network / ui and to the state-aware surface only. That narrowness is why the ui and lifecycle gaps went unnoticed, so the matrix now covers every field a caller can express, on one-shot and all five state-aware phases, with per-row notes for the rows that are not a simple accept/reject. Rows that are accepted-and-ignored are documented as such rather than quietly omitted: process on non-exec state-aware phases, and mis-slotted experimental.isolation_session payloads (the flat user spelling on state-aware, a nested provision/start block on one-shot, and a block under a phase that is not the request's own). Each is a caller supplying a documented field in an undocumented position; the result is a local rather than Entra-backed sandbox, which is a capability downgrade that surfaces downstream as an auth failure. Detecting mis-slotted payloads generically is a cross-backend concern and is deliberately not solved here. §10.3's normative list and the cross-backend contract are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): correct three claims left stale by the policy rescope Two honor-matrix claims described behaviour that existed only while the parser carried the generic mis-slotting rules, which were removed as out of scope. Both are now documented as accepted-and-ignored, matching what the code does: * state-aware-rust.md said the parser refuses a non-exec 'process' section. It does not -- the dispatcher simply reads 'process' only on exec, and nothing runs at the other phases. The matrix rows were updated at rescope but this prose was missed. * oneshot.md said a nested experimental.isolation_session.{provision,start} block is rejected on one-shot. The one-shot mapping reads only the flat 'user', so the nested blocks are ignored. The flat 'user' IS still rejected (validate_runner), so that neighbouring row stands. Both verified by probe against the current parser rather than by inspection, along with every other rejection claim in the two documents. Also removes a stray mid-line carriage return introduced in docs/schema.md, which split a JSON sample line from its trailing comma. The EOL check used until now compared git diff --stat against --ignore-cr-at-eol --stat, which by construction cannot see a CR that is not at end-of-line; a bare-CR scan over every changed file is clean. Documentation only -- no code, no schema, no test changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): state the ui contract plainly and correct four stale claims Review raised that an omitted ui is accepted while the schema says omission equals full lockdown, and proposed requiring an explicit acknowledgment of the real posture, mirroring the network gate. That direction is unsound: there is no truthful ui posture for this backend to acknowledge. The section states intent about the contained code's relationship to the USER's environment, and was modelled on a process/job boundary where 'the clipboard' and 'the desktop' are the user's. An isolation session is a separate OS session, so the contained code keeps its UI capabilities but cannot reach the host's. disable=true denies capabilities the session grants; disable=false promises a GUI the user can never see; every clipboard value describes a relationship to a clipboard the sandbox cannot touch. Only injection=false is honest, and it cannot be supplied alone because the other fields materialize to defaults that are false. An acknowledgment gate needs a true statement to acknowledge, so unlike network there is none available. Documented accordingly, with the field-by-field table in state-aware-rust.md and the rationale in oneshot.md. schema.md's 'omitted ui equals full lockdown' is qualified as per-backend rather than global, and names IsolationSession. ERR_UI_POLICY no longer advises removing the section as though removal were equivalent -- it states that omission is accepted but applies no restriction. Four stale claims corrected, all falsified by this PR's own retained work rather than by the removed work an earlier sweep looked for: * copilot-instructions claimed policy_validation on both surfaces; one-shot discards the typed variant and emits backend_error with the reason in the message. Documented, not changed -- threading a typed code through ScriptResponse touches every backend's one-shot path. * the honor matrix marked an absent network policy rejected on post-provision phases; validate_post_provision_policy gates on network_specified, so absent is inherited. Row split. * the matrix marked every foreign backend section rejected; a lone foreign experimental section on a non-provision phase is accepted and ignored, because those requests carry no containment to compare against. Row split, stable sections kept as rejected. * manager.rs still said lifecycle.destroyOnExit is silently ignored, which this PR made false. Also fixes the preservePolicy message (the backend requires the canonical network acknowledgment rather than rejecting network policy outright) and adds that acknowledgment to both oneshot.md examples, which were non-runnable -- verified through the real binary: both now validate, and the previous shape is refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(models): scope the ui_specified comment to its one consumer Review flagged that 'Used by backends (e.g. IsolationSession)' implies several backends consume this flag when exactly one does, and that the phrasing hides an incomplete story: LXC, Bubblewrap, Seatbelt and Windows Sandbox all still accept and ignore policy.ui, so the Linux and macOS instances of the defect this change fixes remain open. The comment now names IsolationSession as the only consumer today, names the backends that accept-and-ignore, and states the consequence plainly -- the flag being set does not mean a UI policy was honored anywhere, only that the caller supplied one. Comment only; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c --------- Co-authored-by: adpa-ms <> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c
… Preview API (#761) * Migrate isolation_session backend + SDK to the IsolationSession Preview API (#592) * Fix isolation_session_bindings build.rs version-check path resolution * Migrate isolation_session backend + SDK to the IsolationSession Preview API The IsolationSession WinRT surface MXC consumes is now frozen as the Preview namespace. Regenerate the Rust bindings against it and reshape the consumers to the reduced, stable API: - bindings: regenerate from the Preview WinMD; add the windows-crate Foundation feature (the Preview surface references IClosable). - backend (manager/policy/state_aware/one_shot): the OS now assigns an opaque agent user name at provision and validates identity/token at the service, so the sandbox id tail is that opaque name. Collapse the local/Entra provision and start paths into single token-carrying calls, drop host-folder sharing and the per-session sizing profile, and reject all filesystem/network/proxy policy at every phase. Entra is carried by the start config's user bundle rather than inferred from the sandbox id. - domain/wire: remove the sizing-profile config id; regenerate the dev schema and the SDK wire types. - probe: advertise isolation-session availability via `wxc-exec --probe` (probes.isolationSessionAvailable) instead of a registry build pin. - SDK: drop filesystem/configurationId from the typed configs and gate the isolation_session method on the probe fact. Retail CI green: fmt, clippy --all-features, build+test with the feature on and off, SDK unit, schema/sdk-types codegen, and config validation. VM end-to-end validation is pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rework the isolation_session VM E2E corpus for the Preview API The one-shot and state-aware PowerShell suites and their JSON fixtures asserted behavior the Preview migration removed. Bring them in line with the new backend: - drop the filesystem-sharing, path-filter, sizing-profile (configurationId) and start-identity cross-check tests (and their fixtures); - assert that filesystem policy is now rejected (policy_validation) at provision as well as the post-provision phases; - assert the sandbox id tail is the opaque OS-assigned agent user name rather than a client-minted token; - rework the simultaneous-sandbox and concurrent one-shot tests to use per-sandbox %TEMP% markers / a host ACL grant instead of folder sharing; - add a fixture proving an unknown configurationId is gracefully ignored. Verified end-to-end on an isolation-capable VM: one-shot 11/11, state-aware 42/42, SDK node integration 2/2 (55/55). Config schema validation green (157 configs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refresh isolation_session docs and comments after the Preview migration Post-migration review found documentation and comment staleness left behind by the Preview migration (no functional defects). Bring the prose in line with the shipped backend: - rename the three isolation-session docs to drop the "initial-plan" framing (now living specs): initial-bringup-plan -> oneshot and state-aware-{rust,typescript}-initial-plan -> state-aware-{rust,typescript}; update all inbound links (copilot-instructions, sdk/README). - correct the stale policy matrix and prose: filesystem policy is now rejected (policy_validation) at every phase; remove the deleted configurationId / v2-interface / UPN-match / registration content; the sandbox id tail is the opaque OS-assigned agent user name. - scrub residual internal names from MXC prose/comments: IsoEnvBroker, IsoSessionApp.dll, and the pre-Preview Windows.AI.IsolationEnvironment namespace -> Windows.AI.IsolationSession.Preview; genericize bringup-era OS-side names (agent-user format, host binary, worker-process interface). - rewrite the Lifecycle E "registration leak" test comments to the per-agent-user isolation rationale (RemoveUserAsync is per user) and disambiguate two identical assert messages. - refresh the stale configurationId sample in a content-agnostic config_parser test to a user bundle. - fix the IsolationSession row in copilot-instructions (filesystem rejected at every phase; drop ShareFolderBatchAsync/IsoSessionApp.dll). Retail CI green: fmt, clippy, build x64+arm64 with the feature on, unit tests feature on (359) and off (397), wxc_host_prep (17, elevated). Re-verified end-to-end on an isolation-capable VM: 55/55 (one-shot 11, state-aware 42, SDK node 2). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Surface agent SID and shared ephemeral workspace from the Preview API The IsolationSession Preview API gained two provision-time getters on `IsoSessionUserResult` (`AgentUserSid`, `EphemeralWorkspacePath`). Regenerate the bindings and surface both as provision metadata: - bindings: regenerate from the newer Preview WinMD; the only surface change is the two additive getters (the `IIsoSessionUserResult` IID recomputes accordingly). No other interface changed. - manager: `add_user` now returns a `ProvisionedUser` carrying the agent user name, the agent SID, and the shared ephemeral workspace path (read from the three `IsoSessionUserResult` getters). - state-aware: extend `IsolationSessionProvisionMetadata` with `agentUserSid` and `ephemeralWorkspacePath` and populate them at provision. The `sandboxId` tail (the addressing key) is unchanged. - one-shot: adapt the `add_user` call site; one-shot still returns no provision metadata, so it surfaces nothing new. - SDK: add the two fields to the `IsolationSessionProvisionMetadata` type and refresh the unit-test fixtures. The ephemeral workspace is a directory shared between the calling user and the isolated agent user (the caller can stage files into the session through it); each isolated user can access only its own workspace, and it is deleted when the sandbox is deprovisioned. It does not change the workload's working directory. Tests: - Rust unit: provision metadata serializes to exactly the three camelCase wire keys. - VM state-aware E2E (Lifecycle F): metadata presence, caller<->session file sharing, cross-session workspace isolation (a session cannot read a peer's workspace), and workspace deletion on deprovision. - SDK integration: asserts the new metadata fields are present. Validation: fmt, clippy (all-features), build + unit tests feature on and off, wxc_host_prep (elevated), SDK unit, schema/sdk-types codegen, config validation -- all green. Clean-room package build (x64 + arm64) green. VM end-to-end on an isolation-capable build: 62/62 (one-shot 11, state-aware 49, SDK node 2); manual TTY operator-confirmed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: adpa-ms <> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stop the dev schema advertising a stop/deprovision config for IsolationSession (#683) * Stop the dev schema advertising a stop/deprovision config for IsolationSession `wire::IsolationSession` reused a single `IsolationSessionPhase` for all four per-phase state-aware slots, but the backend's `StatefulSandboxBackend` impl declares `StopConfig`/`DeprovisionConfig`/`ExecConfig` as `()`. The generated dev schema and SDK wire types therefore advertised an optional `user` payload for `stop`/`deprovision` that `deserialize_config` rejects at dispatch. Drop the two fields from the wire model and regenerate both artifacts. The SDK never emitted those slots (it lifts `version` to the envelope top level), so only a hand-authored raw-JSON caller reading the schema could be misled; there is no behavior change. Add two regression tests in the iso backend, where both halves of the contract are visible: one pins the wire model's per-phase key set (built field-by-field, so a newly added field breaks the build instead of silently regenerating), the other pins that the `()` config phases reject a payload. The existing codegen gate only proves the artifacts match the wire model, not that the wire model matches the backend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6f3b1916-3f39-4dcc-a420-2ce5db5898bd * Address review O1/O2: precise exec wording, pin the wire->config direction O1 — the doc comment on `wire::IsolationSession` grouped `exec` with `stop` and `deprovision` as "invoked via the top-level `phase` field with `sandboxId`". That is correct for stop/deprovision and incomplete for exec, which also requires the top-level `process` block: `validate_exec_common` rejects an empty `process.commandLine` as `malformed_request`, and the dev schema root carries no `required` array, so this description is the only in-schema guidance a hand-authored caller gets. Separate the two cases and regenerate both artifacts, since the text is copied verbatim into each. O2 — the parity tests pinned only the negative direction (the advertised key set, and that the `()` phases reject a payload), while the section comment claimed they pinned both halves. Add `phases_with_a_config_accept_the_wire_payload`, which derives its payload from `wire::IsolationSessionPhase` rather than a JSON literal and asserts the user bundle survives into `ProvisionConfig` and `StartConfig`. That closes a real gap rather than restating existing coverage: on the state-aware path the wire model is never constructed — the dispatcher deserializes raw JSON straight into the config types — so the `From<crate::wire::IsolationUser>` compile-time guard protects only the one-shot path. A rename of the wire `user` key would leave both config types (`#[serde(default)]`, no `deny_unknown_fields`) silently dropping the bundle, provisioning a local sandbox for a caller who asked for an Entra one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6f3b1916-3f39-4dcc-a420-2ce5db5898bd --------- Co-authored-by: adpa-ms <> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6f3b1916-3f39-4dcc-a420-2ce5db5898bd * fix(isolation-session): refuse network policy the backend cannot enforce; require the unrestricted-network acknowledgment (#682) * Refuse dishonest network policy on the IsolationSession backend The IsolationSession container runs on an unrestricted network (outbound open; a process inside can listen on a localhost-reachable port) that MXC cannot filter or deny. Previously the backend accepted the default `Block` network policy, silently affirming a deny it cannot enforce. Now provision (and one-shot, which runs the full lifecycle) accept ONLY the canonical unrestricted-network acknowledgment: network.defaultPolicy=allow + allowLocalNetwork=true, no allowed/blocked hosts, no proxy, default enforcement. Everything else (including an absent policy, which defaults to the unenforceable `Block`) is refused. Post-provision phases reject any supplied network policy (fixed at provision) via a new domain `ExecutionRequest.network_specified` flag set from wire `network` presence in config_parser; an absent policy is inherited. This closes the domain-model blind spot where an explicit default-valued `Block` is indistinguishable from absent. Reworded the network/proxy error messages and added a post-provision "immutable" message. Updated the happy-path iso test configs to the canonical form; post-provision and negative-path configs are intentionally unchanged. Gates: cargo fmt --check; clippy --all-features; cargo test iso ON and OFF (wxc_common + isolation_session_common green, 84 iso + 456 wxc_common); parser network_specified tests; wxc_host_prep 16 passed (elevated). Pre-existing env failures (microvm e2e staging) are identical iso ON/OFF and unrelated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb * SDK: require the unrestricted-network acknowledgment at iso provision Mirror the Rust-side IsolationSession network fix in the TypeScript SDK. `IsolationSessionProvisionConfig.network` is now a required field typed as the exact literal `{ defaultPolicy: 'allow'; allowLocalNetwork: true }`, so the caller must explicitly acknowledge that the isolation session container runs on an unrestricted network the backend cannot filter or deny. Any other value (or a wrong-typed one) is a compile error; the post-provision configs intentionally expose no `network` field, so the type system enforces the provision-only rule for SDK callers. Tests: type-level @ts-expect-error assertions (required network; block and allowLocalNetwork=false rejected; network rejected on post-provision configs); provisionSandbox lifts the canonical network to the envelope top level; updated the conformance oracle's LiftedPhaseKey and the integration test's provision calls. npm run build + npm test green (207 tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb * docs: IsolationSession network policy is honesty-gated, not rejected Update the in-repo docs to match the new behavior: the backend still rejects all filesystem policy (no host-folder-sharing primitive), but the network policy is now honesty-gated. Provision (and one-shot) require the canonical unrestricted-network acknowledgment (network.defaultPolicy=allow + allowLocalNetwork=true, no host rules, no proxy, default enforcement) and refuse anything else, including an absent policy; post-provision phases reject any supplied network policy and inherit an absent one. Touches: copilot-instructions iso backend row; docs/isolation-session/oneshot (policy-validation coverage row) + state-aware-rust (prose + policy matrix) + state-aware-typescript (provision config table gains the required `network` field); sdk/node/README state-aware example now passes the acknowledgment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb * test: expand IsolationSession network coverage + fix E2E probe Add end-to-end and integration tests for the new network-acknowledgment behavior, and fix a latent bug the required-network change introduced in the SDK E2E probe. Fix (critical): `probeStateAwareRuntime` (the module-load skip probe in the SDK integration suite) provisioned iso with no config. With `network` now required at provision, that returns `policy_validation`, which the probe rethrows — so on an iso-capable host the whole integration suite would error at load instead of running. The probe now passes the canonical network acknowledgment for iso. New integration tests (sdk/node integration, real wxc-exec): the backend refuses a provision that (a) omits the network acknowledgment, (b) sends a non-canonical network (defaultPolicy=block), or (c) omits allowLocalNetwork — each via an untyped call, proving the runtime guard holds even when a JS caller bypasses the compile-time type. New E2E tests (PowerShell VM suites): one-shot refuses block / allow-without- allowLocalNetwork / allowedHosts; state-aware refuses a non-canonical network at provision and refuses any network policy on the start and exec post-provision phases (even the canonical acknowledgment, since it is immutable post-provision). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb * fix: add canonical network ack to iso TTY resize smoke config The IsolationSession TTY resize smoke (run_isolation_session_resize_smoke.ps1) builds its wxc-exec config inline and was the one manual (-Manual-only) test not covered by the automated suite, so it was missed when the network-acknowledgment requirement landed. Without a network block the backend now rejects it with policy_validation. Add the canonical {defaultPolicy:allow, allowLocalNetwork:true} form (matching isolation_session_powershell_interactive.json) so the manual smoke provisions again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb * docs: align state-aware design + GA networking docs with iso network policy The cross-backend state-aware design docs and the v2 GA networking scope doc still described IsolationSession as honoring filesystem (not network) at provision, and showed provision examples using network.allowedHosts -- all now rejected by the backend. Align them with shipped behavior and the SDK type: provision honors only the canonical unrestricted-network acknowledgment ({ defaultPolicy: 'allow', allowLocalNetwork: true }); filesystem policy is rejected and ui is ignored. - networking.md: reword the IsolationSession network-scope line (requires the canonical ack; rejects everything else). - mxc-state-aware-sandbox-api.md / -overview.md: fix the 10.3 honor matrix (iso filesystem applied->rejected, ui applied->ignored), the SDK-exposure prose, the IsolationSessionProvisionConfig type (required network literal; drop filesystem and ui), and the provision examples (drop filesystem, allowedHosts->allowLocalNetwork). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb * docs: address PR review -- stale validator comment, fragile test counts, policy.ui gap state_aware.rs: the block comment above the validate_<phase> hooks still said network policy was rejected at every phase. That stopped being true when provision started accepting the canonical unrestricted-network acknowledgment. Correct the network clause while keeping the statements that are still true (the backend has no network primitive; proxy policy is rejected at every phase). oneshot.md: drop the Test Plan Count column and the '~31 backend-specific' / '287 total currently passing' figures. They were already stale (policy.rs has 28 tests, listed as ~24) and the rows never summed to the stated total. No other backend doc or README tracks test counts, so removing them makes this doc consistent and deletes a number that silently rots whenever a test is added. The durable Category / Location / What-it-verifies content stays. state-aware-rust.md: policy.ui was documented as rejected at every phase, but the backend never validates it, so a supplied UI policy is silently ignored. Correct the matrix to 'ignored' (matching the runtime and the state-aware design doc) and add an explicit known-gap note -- this is the same false-guarantee shape the network honesty gate closes, so rejecting policy.ui is the intended end state, not a deliberate exemption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb * refactor: move network_specified onto ContainerPolicy Addresses PR review feedback: the presence signal describes the content of the policy object, not the invocation, so it belongs beside the other parse-derived policy member (`#[serde(skip)] network_proxy`) rather than next to ExecutionRequest's invocation flags (experimental_enabled, testing_features_enabled, dry_run, audit). Behavior-preserving: the flag is still captured once in the parser (`convert_wire_config`), which is also the path `mxc_engine::build_request` round-trips through, so the Rust SDK / FFI / C# callers are unaffected. `#[serde(skip)]` keeps it off the wire, so the schema and generated SDK types are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb --------- Co-authored-by: adpa-ms <> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb * feat(iso): surface structured error fields on the state-aware wire envelope (#708) * feat(iso): surface structured error fields on the state-aware wire envelope Promote the components of an IsolationSession failure out of the concatenated `message` string and into discrete fields on the wire error envelope: `operation`, `nativeCode` and `remediation`, alongside the existing `code` and `message`. On the state-aware path `message` becomes the bare human-readable text; for a semantic API failure that is the API's own message, passed through verbatim. Wire model (`wxc_common::mxc_error`) - `ApiFailure { operation, native_code?, remediation? }`, held boxed on `MxcError`. Grouping makes the envelope invariant unrepresentable to violate -- `nativeCode` and `remediation` cannot exist without `operation` -- and keeps `MxcError` small enough that every `Result<_, MxcError>` in the workspace stays under clippy's `result_large_err` threshold. - `ErrorEnvelope` gains the three fields, each omitted when unset; `native_code` serialises as camelCase `nativeCode`. IsolationSession backend - `Lifecycle`/`Stale` carry the components structurally instead of a pre-formatted string; `LifecycleFailure::Internal` makes an MXC-side failure structurally incapable of naming an API operation. - Classification split into a pure function so the rules are unit-testable -- `IsoSessionError` is WinRT-activated and cannot be constructed in a test. Same split applied to the activation-failure mapping, which now reports `backend_unavailable` with its operation and HRESULT. - `operation` is interface-qualified, low-cardinality and parameter-free (a failing environment insert names the variable in `message`). - Fixes a latent bug: the `ERROR_NOT_FOUND` -> `stale_id` promotion applied to provision too, which cannot produce a stale id because it mints the sandbox. It is now restricted to non-provision operations, and stays semantic-path only -- a transport HRESULT of the same value has none of the provenance that gives it that meaning, so promoting it would emit a false `stale_id` and tell the caller to destroy a healthy sandbox. One-shot is deliberately untouched: `Display` still composes the full human string, including the category prefix, because that path has no structured envelope to read the fields from. TypeScript SDK - `MxcError` gains a constructor overload taking a flat `MxcErrorFields` object mirroring the wire shape. The positional signature is retained and declared last, so existing callers and `ConstructorParameters<typeof MxcError>` are unaffected. - `mxcErrorFromEnvelope` is the single wire-to-error boundary, including the unknown-code passthrough; all envelope-parsing sites route through it. Also removes several pre-existing OS-internal names from prose in the files touched, per repo convention. Verified on the retail host: cargo fmt, clippy (--all-features), build and test with isolation_session ON and OFF, SDK unit, SDK integration, the versioning gate suite, and wxc_host_prep in an elevated shell -- all green. The iso E2E suites still need a VM run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 67a135de-4549-4c4e-8428-6f2a993e9bf2 * fix(iso): never emit an empty error message; state operation-value stability Addresses both optional findings from the review of #708. O1 -- the wire `message` could be empty. `Message()` is a best-effort WinRT getter, and with the operation and HRESULT now in their own fields nothing backfills `message`, so a failed or empty getter reached the wire as `"message": ""`. The change was internally inconsistent about it: the `Err(Code())` arm already guarded the empty case, and `remediation` normalised empty-to-absent, but the `Ok(code)` arm passed the raw string through. Both best-effort getters now collapse to `Option` at the WinRT boundary and `IsoApiFailure::new` decides what absent means per field -- a stand-in for `message`, which the wire requires, and absence for `remediation`, which is optional. Normalising at construction rather than per branch is what keeps the guarantee from having to be restated at each call site; every construction path routes through it. O2 -- `operation` values are now published in the SDK README, recommended for telemetry aggregation, and pinned by an E2E assertion, but nothing said whether they are stable. They mirror the projected WinRT class and method names, which this repo does not own and cannot version, so they are now documented as best-effort diagnostics rather than a versioned contract, in the cross-backend contract, the backend spec, and the SDK README. The E2E assertion that pins an exact value carries a note explaining why pinning is correct there specifically: it verifies MXC's own mapping and moves with the constant. Also verified the boxing rationale the review could not check without running clippy: `MxcError` is 72 bytes as written and would be 136 inlined, against the default 128-byte `result_large_err` threshold. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 67a135de-4549-4c4e-8428-6f2a993e9bf2 * fix(errors): address PR review round 2 Fixes found by review of the structured-error-fields change. - transport_err no longer emits a dangling "step: " when the platform supplies no message text. An HRESULT with no OS message-table entry (0xDEADBEEF, and any custom facility code) returns an empty message(), and joining unconditionally produced a technically-non-empty string that slipped past the empty-message guard in IsoApiFailure::new. Fall back to the step alone. ~34 call sites route through this one join. - MxcError::Display now renders the API detail when present, so a consumer that only logs the error keeps the operation and status that used to be concatenated into message. Rendering only: the wire envelope still carries message bare, with the components in their own fields. Replaces the thiserror derive with explicit Display + Error. - The Code()-getter-failure branch moves into unreadable_code_failure, a pure function, so its composition is reachable from a unit test. format_iso_error stays a thin WinRT adapter. - Correct the ApiFailure doc comment: grouping makes the invariant the easy path, not an unrepresentable-to-violate one (Default was derived and the fields are pub). Drop the unused Default derive. - Add #[non_exhaustive] to MxcError and ErrorEnvelope so future fields are a non-event for other workspace crates. - Guard the MxcError constructor against a nullish argument, which took the object branch and failed inside super() with a TypeError naming "message". Default the positional message rather than asserting it. - Un-export WireError and mxcErrorFromEnvelope: they exist so the SDK's own parse sites share one widening point. MxcErrorFields stays exported because it is the parameter type of a public constructor overload -- hiding the name leaves the type usable but unnameable. - Lift the four host-independent policy-validation cases out of the probe-gated suite. Both CI systems set MXC_SKIP_OS_BUILD_DEPENDENT_TESTS=1, so nothing in that suite ran in CI; these need the isolation_session feature compiled in but not a host that can run isolation sessions. - Document that the structured fields are currently populated only by IsolationSession state-aware operations. Gates: fmt; clippy --all-targets --all-features -D warnings; Rust build + test iso ON and iso OFF (wxc_host_prep 16/16 elevated); SDK unit 231/0; SDK integration 45/0 with the four lifted cases now executing under the CI skip flag; versioning + dotnet parity 7/7; VM suites 73 passed / 0 failed with an empty leak delta; manual TTY tests confirmed by the operator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 67a135de-4549-4c4e-8428-6f2a993e9bf2 --------- Co-authored-by: adpa-ms <> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 67a135de-4549-4c4e-8428-6f2a993e9bf2 * fix(isolation-session): refuse ui and unsupported lifecycle rather than dropping them (#718) * docs(isolation-session): correct claims that no longer match the code oneshot.md listed lifecycle.destroyOnExit under Implemented and described it as mapped to an OS-side lifetime policy; the backend has no such knob and silently ignored the field. It also claimed the runner does not use stdin, terminate, control signals or ConPTY -- all four are used -- and carried a stale ConPTY deferral plus pre-Preview-API type names. schema.md omitted the ui section from the Full Schema example and had no ui field table at all, and stated that other backend sections are ignored when validate_single_backend_section rejects them. isolation_session and hyperlight were missing from the concrete-backends table. The cross-backend design doc described IsolationSessionProvisionMetadata as one field when it carries three, and its containerId claim did not match the parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * fix(isolation-session): refuse ui and unsupported lifecycle rather than dropping them A caller could supply a schema-valid ui policy in the documented slot on the correct surface and silently receive none of it. That is the caller doing everything right and still losing a security control they believe is applied, so the backend now refuses it at every phase on both surfaces with policy_validation. The refusal is presence-based via a new ContainerPolicy::ui_specified flag, the twin of network_specified. UiPolicy::default() is full lockdown, so an explicitly-supplied lockdown ui is indistinguishable by value from an absent one; without a presence bit the backend cannot tell 'caller asked for lockdown' from 'caller said nothing'. Measured on a live session before choosing to refuse rather than treat lockdown as vacuously satisfied: window creation, GetDC/GetDeviceCaps/GetSystemMetrics and the session's own clipboard all succeed inside the session; only SendInput is denied. The session isolates the HOST's UI from contained code, but the ui fields are written as capability denial, so accepting them would assert a Win32k attack-surface reduction that is not delivered. lifecycle is refused by value on one-shot, where the defaults do match reality: the backend always stops the session and removes the agent user before returning, which is exactly destroyOnExit=true. Only destroyOnExit=false and preservePolicy=true are refused. State-aware already rejected the whole section. Tests at all three tiers: Rust unit for every phase, absent and lockdown-equivalent cases, the filesystem -> ui -> network precedence and ui_specified on both surfaces; a new e2e_isolation_session_policy.rs whose refusals all happen in validate_* before any OS-side call, so it needs no isolation-capable host and skips cleanly when the feature is off, with an over-rejection guard; and Node integration guards, since ui is reachable from plain JS even though the typed per-phase configs exclude it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): publish the full policy honor matrix for both surfaces The state-aware design's §10.3 scopes the required honor matrix to filesystem / network / ui and to the state-aware surface only. That narrowness is why the ui and lifecycle gaps went unnoticed, so the matrix now covers every field a caller can express, on one-shot and all five state-aware phases, with per-row notes for the rows that are not a simple accept/reject. Rows that are accepted-and-ignored are documented as such rather than quietly omitted: process on non-exec state-aware phases, and mis-slotted experimental.isolation_session payloads (the flat user spelling on state-aware, a nested provision/start block on one-shot, and a block under a phase that is not the request's own). Each is a caller supplying a documented field in an undocumented position; the result is a local rather than Entra-backed sandbox, which is a capability downgrade that surfaces downstream as an auth failure. Detecting mis-slotted payloads generically is a cross-backend concern and is deliberately not solved here. §10.3's normative list and the cross-backend contract are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): correct three claims left stale by the policy rescope Two honor-matrix claims described behaviour that existed only while the parser carried the generic mis-slotting rules, which were removed as out of scope. Both are now documented as accepted-and-ignored, matching what the code does: * state-aware-rust.md said the parser refuses a non-exec 'process' section. It does not -- the dispatcher simply reads 'process' only on exec, and nothing runs at the other phases. The matrix rows were updated at rescope but this prose was missed. * oneshot.md said a nested experimental.isolation_session.{provision,start} block is rejected on one-shot. The one-shot mapping reads only the flat 'user', so the nested blocks are ignored. The flat 'user' IS still rejected (validate_runner), so that neighbouring row stands. Both verified by probe against the current parser rather than by inspection, along with every other rejection claim in the two documents. Also removes a stray mid-line carriage return introduced in docs/schema.md, which split a JSON sample line from its trailing comma. The EOL check used until now compared git diff --stat against --ignore-cr-at-eol --stat, which by construction cannot see a CR that is not at end-of-line; a bare-CR scan over every changed file is clean. Documentation only -- no code, no schema, no test changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(isolation-session): state the ui contract plainly and correct four stale claims Review raised that an omitted ui is accepted while the schema says omission equals full lockdown, and proposed requiring an explicit acknowledgment of the real posture, mirroring the network gate. That direction is unsound: there is no truthful ui posture for this backend to acknowledge. The section states intent about the contained code's relationship to the USER's environment, and was modelled on a process/job boundary where 'the clipboard' and 'the desktop' are the user's. An isolation session is a separate OS session, so the contained code keeps its UI capabilities but cannot reach the host's. disable=true denies capabilities the session grants; disable=false promises a GUI the user can never see; every clipboard value describes a relationship to a clipboard the sandbox cannot touch. Only injection=false is honest, and it cannot be supplied alone because the other fields materialize to defaults that are false. An acknowledgment gate needs a true statement to acknowledge, so unlike network there is none available. Documented accordingly, with the field-by-field table in state-aware-rust.md and the rationale in oneshot.md. schema.md's 'omitted ui equals full lockdown' is qualified as per-backend rather than global, and names IsolationSession. ERR_UI_POLICY no longer advises removing the section as though removal were equivalent -- it states that omission is accepted but applies no restriction. Four stale claims corrected, all falsified by this PR's own retained work rather than by the removed work an earlier sweep looked for: * copilot-instructions claimed policy_validation on both surfaces; one-shot discards the typed variant and emits backend_error with the reason in the message. Documented, not changed -- threading a typed code through ScriptResponse touches every backend's one-shot path. * the honor matrix marked an absent network policy rejected on post-provision phases; validate_post_provision_policy gates on network_specified, so absent is inherited. Row split. * the matrix marked every foreign backend section rejected; a lone foreign experimental section on a non-provision phase is accepted and ignored, because those requests carry no containment to compare against. Row split, stable sections kept as rejected. * manager.rs still said lifecycle.destroyOnExit is silently ignored, which this PR made false. Also fixes the preservePolicy message (the backend requires the canonical network acknowledgment rather than rejecting network policy outright) and adds that acknowledgment to both oneshot.md examples, which were non-runnable -- verified through the real binary: both now validate, and the previous shape is refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * docs(models): scope the ui_specified comment to its one consumer Review flagged that 'Used by backends (e.g. IsolationSession)' implies several backends consume this flag when exactly one does, and that the phrasing hides an incomplete story: LXC, Bubblewrap, Seatbelt and Windows Sandbox all still accept and ignore policy.ui, so the Linux and macOS instances of the defect this change fixes remain open. The comment now names IsolationSession as the only consumer today, names the backends that accept-and-ignore, and states the consequence plainly -- the flag being set does not mean a UI policy was honored anywhere, only that the caller supplied one. Comment only; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c --------- Co-authored-by: adpa-ms <> Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c * feat(isolation-session): carry an optional appId in a structured sandboxId (#746) * refactor(isolation-session): remove the one-shot backend config surface The one-shot IsolationSession path takes no configuration. Its only field, `user`, existed solely because the state-aware `StartConfig` reused the one-shot domain struct -- so one-shot had to reject its own struct's only field at runtime. That rejection was guard code compensating for the deliberately permissive `experimental` block (no `deny_unknown_fields`). Such guards are scaffolding that graduation to the closed stable surface deletes anyway, so ignoring is the correct behaviour and matches every other unrecognised key there. - wire `IsolationSession` loses `user`; it now carries only the state-aware `provision` / `start` nesting. - domain `IsolationSessionConfig` becomes `IsolationSessionStartConfig`, which is what it always was in practice. - `ExperimentalConfig.isolation_session` is deleted outright. Nothing is lost: the multi-backend conflict check reads the *wire* struct (`present_backend_sections` takes `&wire::MxcConfig`), which survives. Caller-visible behaviour change: `experimental.isolation_session.user` on a one-shot request changes from a loud error to being silently ignored. It is unreachable from the typed Node SDK, whose one-shot `ContainerConfig.experimental` exposes only `wslc` and `telemetry`. Tests: the deleted rejection tests are replaced, not dropped -- one asserting the field is now accepted and ignored, and one pinning that a lone `experimental.isolation_session` section still marks a configured backend so the conflict check cannot silently regress. Schema and SDK wire types regenerated (not hand-edited). * fix(isolation-session): trim the UPN consistently at validation and at the OS call `validate_isolation_session_user` trimmed the UPN before its shape check, but provision and start handed the OS the untrimmed value. A padded UPN such as " alice@contoso.com " therefore passed validation and reached the OS with its surrounding spaces intact -- validation and transmission disagreed about what the accepted value was. Extract `os_credentials`, which produces the exact (entraAccountName, wamToken) pair given to the OS, and apply the trim there so the two agree. An absent bundle maps to the local-agent empty pair. The WAM token is deliberately NOT trimmed: it is an opaque bearer credential and trimming could corrupt it. The helper exists because the previous inline `match` offered no seam -- the behaviour could not be asserted without a live OS service. It is now covered by unit tests for the trim, the verbatim token, the absent bundle, and the interior-whitespace case. * refactor(isolation-session): split the wire phase type per phase `wire::IsolationSessionPhase` was shared by provision and start, so the generated schema advertised every per-phase field on both phases regardless of which one actually accepts it. The domain configs and the Node SDK types were already split per phase; only the wire model pooled them. Replace it with `IsolationSessionProvisionPhase` and `IsolationSessionStartPhase`. The JSON keys (`provision`, `start`, `user`) are unchanged, so this is invisible on the wire -- it only makes the generated schema and SDK wire types state truthfully where each field is legal. The SDK conformance oracle is now per-phase rather than a single pooled key set, which is strictly stronger: a field legal only on provision can no longer satisfy it by appearing on the start config. The phases whose Rust associated type is `()` are asserted to expose no backend-specific field at all. Also add a non-vacuity guard to that oracle. Every assertion is of the form `Exclude<A, B> extends never`, which passes trivially if `A` resolves to `never` -- so a mistake in the derivation would have silently disabled the check instead of failing it. The derived key sets are now pinned to their expected contents. Schema and SDK wire types regenerated (not hand-edited). * feat(isolation-session): carry an optional appId inside the sandboxId Accept an optional `appId` on the state-aware provision phase -- the Package Family Name for a packaged application, any string for an unpackaged one -- and carry it inside the returned `sandboxId`. Motivation: future OS API changes will act on the calling application's PFN, and those calls are expected to be spread across lifecycle phases. It is not guaranteed that the OS will propagate a PFN supplied at provision to a session's other calls. MXC holds no cross-phase state (each phase is a fresh process), so the only carrier that survives without the caller re-supplying the value on every phase is the sandboxId itself. Embedding works whether or not the OS ends up retaining it. Nothing consumes appId yet. It is accepted, encoded, and decoded back into an internal struct, deliberately exposed nowhere -- scaffolding for a future OS contract, so adopting it later is not a breaking change. New id format, replacing the plaintext `iso:<agentUserName>` tail: iso:<base64url-nopad( JSON object )> with v1 keys `version`, `agentUserName`, and optional `appId`. Encoded rather than delimited because the parser must know which fields are present without assuming anything about separator characters: the agentUserName is OS-assigned with no charset guarantee, so a delimited form would mis-parse a name containing the delimiter *silently*. The base64url alphabet makes that entire class of bug unrepresentable rather than merely prevented. The envelope is frozen (always base64url of a JSON object; all evolution happens as keys inside). The version gate is one-directional -- a payload from a newer MXC is rejected with a message that says so, since the remediation is "upgrade MXC", not "this id is corrupt" -- and is bumped only for changes an old reader must not silently mishandle. Unknown keys are ignored. appId validation is structural only (no control characters, at most 256 characters). MXC is a pass-through carrier and does not judge what a valid application identity looks like; a PFN grammar check would risk rejecting forms a future OS API accepts. The value is preserved verbatim, and an explicitly empty string is a value distinct from absent -- a future OS API may assign it meaning, so MXC neither collapses the two nor ever synthesizes an empty string the caller did not send. Legacy plaintext ids no longer decode and surface as `malformed_id`. Intended: they refer to OS resources that do not survive the change either. Tests: exhaustive codec unit tests (round-trips, empty-vs-absent distinctness, verbatim preservation, hostile agent-user names containing colons and path separators, determinism, the alphabet property, every decode failure mode, the version gate); provision-hook validation tests; SDK type and envelope tests including a compile-time assertion that appId is rejected at start; and E2E coverage for the round-trip, the empty case, both rejections, legacy ids, and newer-version ids. * fix(isolation-session): address review findings on appId/sandboxId Five rounds of review against the four preceding commits. Grouped by what they fix rather than by the round they surfaced in. Correctness -- the id codec - Restore the non-empty agentUserName invariant. The base format guaranteed it structurally (`!rest.is_empty()` applied to the tail, which WAS the name); the rewrite applied that check to the base64 tail, catching only a bare `iso:`. {"version":1,"agentUserName":""} decoded cleanly and handed an empty string to the OS lifecycle calls, which answer "not found" -- surfacing as stale_id ("re-provision") for a request that was never well-formed. - Re-validate appId on decode. sandboxId is caller-supplied on every post-provision phase, so provision is not the only way a value arrives; the guarantee now holds by value rather than by provenance. Mapped to malformed_id, NOT policy_validation: every other decode failure is malformed_id, a bad id is an id problem, and the phases that consume an id accept no policy for a policy error to belong to. - Decode the id in validate_exec / validate_stop / validate_deprovision. Previously only validate_start decoded, so --dry-run (which stops after validation) reported success for ids the real call rejects. The asymmetry is pre-existing -- all three hooks ignored the id at base -- but this change widens the class of ids that fail, so it is closed here. Documentation -- four false or incomplete claims - The legacy-id justification was wrong three times in succession, each correction exposing the next. It is not true that the referenced resources do not survive the change: the agent user account persists until explicit deprovision. It is not true that the session does not outlive the binary: outliving the process is the premise of the state-aware lifecycle, and nothing in MXC stops a session when the binary is replaced. It is not true that such a sandbox becomes unaddressable through MXC: decode binds nothing to the minting binary, so re-encoding the old agent user name -- which a legacy id carries in the clear -- yields a valid id for the same sandbox, making recovery unconditional rather than contingent on having recorded anything. Also corrected: a session ends at deprovision too, since removing the agent user terminates any session still running under it. - A doc this change edits still claimed one-shot rejects experimental.isolation_session.user; the correction had been applied at one location and missed at the parallel statement 200 lines later. - The appId JSDoc promised `null` as a spelling of absent on a `string` property, so a caller following it got a compile error. Claim removed; the wire-level behaviour is unchanged and documented where it applies. - state-aware-typescript.md enumerated the provision config but omitted appId. Tests -- the recurring defect, and the rule that ends it Four rounds surfaced the same class of flaw: a test that exercises the fixed path without discriminating it from the fix's absence. Each would have passed unchanged against the pre-fix head. - A case titled "every id-consuming phase rejects a legacy id" never issued a dry run -- the harness had no dry-run parameter at all. Added -DryRun to Invoke-StateAware and exercised each phase both ways, plus the missing counterpart: a well-formed id must be ACCEPTED by --dry-run on every phase, or the agreement would be satisfied trivially by refusing everything. - The crafted-appId case spliced a raw U+0007 into the JSON text, which RFC 8259 forbids inside a string, so serde_json rejected it at parse time and the payload never reached validate_app_id. Written as \u0007 so the document is valid and the control character survives into the decoded string, and the message is asserted to name `appId` -- which is what distinguishes validate_app_id running from the parser refusing. An oversized-appId case is added, having no JSON-level analogue and so unable to pass for the wrong reason. - The dry-run tests asserted only exit codes, which are identical either way. They now assert the result envelope, and the exec command prints a marker and exits 1 so a dropped flag is caught three independent ways. - That envelope assertion in turn overclaimed: start/stop/deprovision return metadata: None, rendered as the same {"result":{}} the dry-run short-circuit produces, so it discriminates nothing for those three. The comment is scoped to what it proves, and the real observable is added where it can live -- wxc_common's dispatch tests, whose call-counting StubBackend already pinned dry-run for provision and exec. start, stop and deprovision were simply missing. Dry-run skipping is now pinned for all five phases at the layer where dry_run actually lives, in Rust tests that run in the local review loop rather than only on the VM. Verified by mutation rather than by reasoning: making the Start arm ignore dry_run fails dispatch_start_dry_run_skips_start_call_but_runs_validate, and only that test. The rule going forward is to make the fix's absence produce a failure and then observe it. Harness -- a leak inside the leak discipline The positive dry-run case provisions a real sandbox, and Run-StateAwareTest swallows throws while the suite's finally reclaims only $script:sandboxId, which that case never set. A throw between provision and cleanup therefore leaked an Indefinite-lifetime agent user outside the harness's own leak discipline -- the discipline cited as evidence elsewhere in this review. The id is now reclaimed in a dedicated try/finally, and the cleanup's exit code is asserted rather than discarded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c82ca12-a566-4257-9de7-164be299375e --------- Co-authored-by: adpa-ms <> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c82ca12-a566-4257-9de7-164be299375e * refactor(isolation-session): remove the Entra enterprise user API Enterprise support is not ready to ship in main. This removes the Entra `user` API (`{ upn, wamToken }`) from the isolation_session backend so the main-bound branch carries no enterprise surface. It is restored on feature/isolation-session-internal by the commit that follows. Scope is strictly the `user` API. Everything else on the branch -- the IsolationSession Preview API migration, `appId` in the structured `sandboxId`, structured error fields, and the network/UI policy rejection work -- is untouched. Rust - Wire model: drop `IsolationUser`, `IsolationSessionStartPhase`, the `start` slot on `IsolationSession`, and `user` from the provision phase. Provision is now the only phase carrying a per-phase wire object, which is what the generated schema advertises. - Domain model: drop `IsolationSessionUser` and `IsolationSessionStartConfig`. The latter's only field was `user`, so the type has nothing left to carry -- matching Rust's existing pattern, where a phase type exists only if the phase contributes a wire object (exec/stop/deprovision already use `()`). #683 made exactly this change for stop and deprovision; start now joins them. - Backend: `type StartConfig = ()`. Remove `os_credentials` and the start-phase shape validation. `IsoSessionOps.AddUserAsync` / `StartSessionAsync` keep the OS-defined optional account/token parameters -- MXC now always passes empty strings, which is what the local-agent path already did -- so the generated `bindings.rs` is untouched. - `policy.rs`: remove `validate_isolation_session_user`. TypeScript - Remove the `IsolationSessionUserConfig` class (and its `wamToken` inspect redaction) and the `user` fields on the provision and start configs. - KEEP `IsolationSessionStartConfig` as `{ version?: string }`. Deleting it would break the pattern rather than follow it: five sibling interfaces are already version-only, including `WindowsSandboxStartConfig` -- the same phase on the other state-aware backend -- and `ConfigsForBackend` requires all five phase keys per backend. `version` is also not vestigial: state-aware-helper lifts it out of the backend object onto the envelope as the request's schema version, so removing the type would leave `start` on isolation_session as the only (backend, phase) pair a caller cannot version. Tests - Wire-conformance: the start-phase equivalence assertions are replaced by `_StartNoBackendKeys`, joining the existing exec/stop/deprovision group. Deleting only the failing `_StartKeysNonVacuous` guard and keeping the equivalences would have left three assertions passing because both sides are `never` -- vacuously true, which is precisely what that guard exists to catch. - `phases_without_a_config_reject_a_payload` now covers `start`, pinning that it moved into the no-config group rather than merely losing a field. - The SDK integration test "a policy rejection reaches the SDK with no structured failure fields" is preserved with a different trigger rather than deleted. It used a malformed UPN only as a vehicle; the contract it pins -- `operation`/`nativeCode`/`remediation` absent when no API call was in flight -- ships to main with #708 and applies to every policy rejection on every backend. It is also the only END-TO-END coverage of that contract; the sibling assertions in `errors.test.ts`, `state-aware.test.ts` and Rust `error.rs` all use fabricated envelopes. It now triggers on an oversized `appId` -- the same MXC-side, pre-API-call rejection. - The two `state_aware_request` secret-redaction tests are DELETED rather than rewritten. They were written specifically to demonstrate the `wamToken` path, and every link they covered is pinned elsewhere: `config_deserialize`'s own self-contained tests already assert redaction on a fully-qualified path (`experimental.someBackend.user`), and 13 non-secret tests in the same file cover prefix construction and whole-file line reporting. After this change no config field in the repo matches a secret marker, so the composition they exercised is unreachable. - The one-shot stray-config test is renamed, not dropped: it pins that an unrecognised `experimental.isolation_session` key is ignored rather than rejected, which nothing else covers. A key naming nothing real tests that better than `user` did. Docs - `docs/isolation-session/state-aware-rust.md` and `state-aware-typescript.md` are the authoritative per-backend specs: the provision/start `user` rows, the `IsolationSessionUserConfig` section, the honor-matrix rows and the Entra worked example are removed, and Start is restated as taking no per-phase config. - `docs/schema.md` loses the now-invalid `"isolation_session": { "start": { "user": … } }` nesting example. This is required by the repository convention that a config-field removal updates `docs/schema.md` alongside the generated schema (`.github/copilot-instructions.md`). The example was doubly wrong after this change: `user` no longer exists, and `start` accepts no object at all, so a caller copying it would get a hard dispatch error rather than a tolerated unknown key. - `docs/schema-codegen.md` no longer lists the `user` bundle among the objects the generated schema closes, and its per-phase nesting list is reduced to `isolation_session.provision`. (That list was also stale for `stop` / `deprovision` from #683; the whole line is corrected rather than only the part this change falsified, since a partial fix would still be wrong.) - `docs/isolation-session/oneshot.md`, `docs/windows-sandbox/windows-sandbox.md`, `docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md` and `.github/copilot-instructions.md` drop their Entra references, including three instances of the same "WindowsSandbox has no Entra `user` bundle" contrast that is meaningless once no backend has one. Deliberately not corrected here: the illustrative `"start": { "configurationId": … }` examples in `docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md`. `configurationId` was already dead before this change and the block is self-caveated as illustrative. This change does alter *why* those examples are wrong -- the shape itself is now rejected, not just the field -- but the correct content differs between this branch and the feature branch that restores the `user` bundle, so a standalone follow-up lands it once instead of being reverted and re-applied. `config_deserialize`'s secret-redaction machinery is generic infrastructure and stays; only its fixtures and the `SECRET_PATH_SEGMENTS` comment are reworded off the enterprise example. The telemetry threat-model references to UPN are not enterprise surface -- they document that the correlation-vector base is never seeded from caller identity -- and are left alone. The C# SDK is untouched: this branch does not modify `sdk/dotnet`, and it cannot reach any experimental backend today (`experimental_enabled` is never set on the mxc-sdk -> mxc_ffi path), so its dead `SandboxUserCredentials` is tracked as a separate deliverable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c82ca12-a566-4257-9de7-164be299375e * fix(isolation-session): address review feedback on the Preview API migration Collapses the review-round fixes for the IsolationSession Preview API migration into a single change. Grouped by what they fix. Lifetime and resource safety: * Do not leak the agent user when provisioning fails after the account is minted. `add_user` now returns the provisioned user alongside the manager so every early-return path can deprovision it, and the post-mint failure paths run a best-effort deprovision. * Stop passing a stack pointer to the stdio relay threads. The relay cannot be interrupted while blocked in `ReadFile`, so no join can be guaranteed to complete and any timed-out join returns into the same use-after-free. The parameters are heap-owned instead, which removes the window rather than narrowing it. Policy correctness: * Emit `ui` only when the caller supplied one, so an omitted `ui` is not silently materialised into a lockdown policy the backend would then refuse. Covered by regression tests. Test-gate correctness -- several gates were passing without testing anything: * Probe the backend by asking the binary under test, and treat a missing or non-boolean probe value as "unavailable" rather than truthy. The previous gate keyed on a hard-coded DLL path and WinRT class registry key, which stopped tracking what the code activates once the backend moved to the Preview API. * Make the state-aware config exhaustiveness guard actually fire. It asserted through `x as never`, which is always a legal assertion, so the guard could never fail; the switch subject must be a bare reference for narrowing to apply. Likewise distribute the all-optional-config check over the backend union, since a single all-optional member otherwise satisfies it for every backend. * Skip the policy-validation suite on a build without the isolation_session feature instead of failing it, while still running it on hosts that merely lack IsolationSession runtime support -- those refusals are raised before any OS call, and that is the coverage the suite exists for. Its gate is computed above every `describe`, because the suite runs with `--test-force-exit` and a `describe` registering after a top-level `await` is dropped silently, with exit 0. Also repairs the SDK integration build, makes required state-aware config explicit in the TypeScript surface, and corrects the state-aware design docs and worked examples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c82ca12-a566-4257-9de7-164be299375e --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: adpa-ms <> Copilot-Session: 6f3b1916-3f39-4dcc-a420-2ce5db5898bd Copilot-Session: 47662115-cf22-4564-8930-370dfbee63fb Copilot-Session: 67a135de-4549-4c4e-8428-6f2a993e9bf2 Copilot-Session: 35b9aab9-16c9-4897-bcdb-f7188a51175c Copilot-Session: 0c82ca12-a566-4257-9de7-164be299375e
📖 Description
Follow-on to #682, same backend and same seam: policy the IsolationSession backend cannot honor was accepted and silently dropped instead of refused.
uiis refused at every phase on both surfaces, and nouiposture is truthful for this backend — there is no value combination that could be accepted instead. The section states intent about the contained code's relationship to the user's environment, and was modelled on a process/job boundary where "the clipboard" and "the desktop" are the user's. An isolation session is a separate OS session: the contained code keeps its UI capabilities but cannot reach the host's. Sodisableeither denies capabilities the session grants or promises a GUI the user can never see, and everyclipboardvalue describes a relationship to a clipboard the sandbox cannot touch. Onlyinjection: falseis honest, and it cannot be supplied alone because the other fields materialize to defaults that are false. With nothing truthful to accept, there is no acknowledgment-style gate as there is fornetwork.The check is presence-based via a new
ContainerPolicy::ui_specifiedflag, the twin ofnetwork_specified:UiPolicy::default()is full lockdown, so an explicit lockdownuiis indistinguishable by value from an absent one. An omitteduiis accepted and applies no restriction —docs/schema.md's default-deny reading is now qualified as per-backend rather than global, since it never held for a backend that does not enforce the section.lifecycleis refused by value on one-shot, where the defaults do match reality: the backend always stops the session and removes the agent user before returning, which is exactlydestroyOnExit: true. OnlydestroyOnExit: falseandpreservePolicy: trueare refused. State-aware already rejected the whole section;oneshot.mdhad listeddestroyOnExitunder "Implemented".Docs.
oneshot.mdanddocs/schema.mdcarried claims that no longer matched the code —schema.mddocumented nouisection at all, and said foreign backend sections are ignored when they are rejected. The honor matrix instate-aware-rust.mdnow covers every field a caller can express across both surfaces, rather than the three-field state-aware minimum §10.3 requires; that narrowness is why these gaps went unnoticed. Rows that are accepted-and-ignored are documented as such rather than omitted, and the matrix distinguishes per-surface error codes:policy_validationon state-aware,backend_erroron one-shot, which discards the typed variant.Scope. IsolationSession only. The single parser change is one line (
policy.ui_specified = ui.is_some();) — everything else lives in the backend's own validators. No schema bump, no wire-shape change, no SDK type changes, no cross-backend rules.Deliberately out of scope: detecting mis-slotted
experimental.isolation_sessionpayloads. Those are documented fields in undocumented positions — a caller error rather than a surprise from a correct request — and generic detection is a cross-backend concern. The matrix documents the resulting behaviour. Windows Sandbox has the same unhandleduipolicy; also out of scope.🔗 References
Follows #682 (network policy) and #683 (dev-schema drift), both on this branch.
🔍 Validation
Host:
fmt;clippy --all-features -D warnings; workspace release tests with iso ON and OFF;wxc_host_preprun elevated (its tests need admin); versioning suite over 191 configs;sdk/nodebuild + 203 unit tests.Tests at all three tiers. Rust unit covers the refusal at every phase, absent and lockdown-equivalent cases, the filesystem → ui → network precedence, the
lifecyclevalues, andui_specifiedon both surfaces. A newe2e_isolation_session_policy.rsneeds no isolation-capable host — every refusal happens in avalidate_*hook before any OS-side call — and skips cleanly when the feature is off; it includes an over-rejection guard. Node integration adds guards becauseuiis reachable from plain JS even though the typed per-phase configs exclude it.Isolation VM: 78 passed, 0 failed, 0 skipped, plus the three operator-judged interactive tests (TTY resize, streaming, interactive PowerShell with exit-code propagation). No leaked agent accounts or leftover directories, diffed against a pre-run baseline.
✅ Checklist
📋 Issue Type