Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,16 @@ wxc-exec.exe --audit policy.json

> **Warning:** `--audit` injects `permissiveLearningMode` β€” AppContainer restrictions are **not** enforced for the duration of the run. Use only for policy authoring. `learningModeLogging` and `permissiveLearningMode` are reserved internal capability names and are rejected in `processContainer.capabilities`; use `processContainer.learningMode: true` for deny-and-record mode or `--audit` for permissive mode. See [docs/learning-mode/capabilities.md](docs/learning-mode/capabilities.md) for the three learning-mode flows.

### Wait for Debugger

`--wait-for-debugger` holds the sandboxed child suspended right after creation β€” before it has run any code, including its own loader initialization β€” instead of resuming it immediately. This lets an external, unsandboxed debugger attach to the *real* sandboxed process by PID at its own pace (there's no timeout, since nothing can run until a debugger attaches):

```bash
wxc-exec.exe --wait-for-debugger config.json
```

Attach a debugger to the printed PID and set breakpoints (necessarily deferred/pending ones, since no dependent DLL past the main image has loaded yet). The instant `wxc-exec` detects the attach it automatically clears its own suspend hold, so a plain `g` is all you need afterward β€” no `~0 m` ("Resume Thread") required. Windows-only; only the AppContainer tier of the `processcontainer` backend implements the suspend point (BaseContainer is skipped automatically, since it cannot guarantee `CREATE_SUSPENDED` is honored on every OS build).

## Telemetry (Experimental)

MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`MXC.Execution` and `MXC.Error`) are emitted to the local ETW subsystem via the Rust [`tracelogging`](https://crates.io/crates/tracelogging) crate. Every event includes common fields (Version, Channel, IsDebugging, `UTCReplace_AppSessionGuid`) as Part C custom event data.
Expand Down
18 changes: 17 additions & 1 deletion src/backends/appcontainer/common/src/appcontainer_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1339,7 +1339,23 @@ impl SandboxBackend for AppContainerScriptRunner {
return Err(ScriptResponse::error(&e.to_string()));
}
};
if let Err(e) = child.resume() {

// `--wait-for-debugger`: block (no timeout) until an external
// debugger attaches to the real sandboxed PID, then clear only
// wxc-exec's own CREATE_SUSPENDED hold β€” see `debugger_wait` module
// docs for why this leaves the debugger's own attach-time freeze in
// place (so a plain `g` is enough, no `~0 m` needed).
if request.wait_for_debugger {
if let Err(e) = crate::debugger_wait::wait_for_debugger_then_resume(
child.process.get(),
child.thread.get(),
child.pid,
logger,
) {
self.teardown(&mut prepared, request.lifecycle.preserve_policy, logger);
return Err(ScriptResponse::error(&e.to_string()));
}
} else if let Err(e) = child.resume() {
self.teardown(&mut prepared, request.lifecycle.preserve_policy, logger);
return Err(ScriptResponse::error(&e.to_string()));
}
Expand Down
111 changes: 103 additions & 8 deletions src/backends/appcontainer/common/src/base_container_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1168,14 +1168,69 @@ impl BaseContainerRunner {
}
};

// The child was created suspended; now that it is in the job object (so
// every descendant it spawns is captured), resume its main thread. If the
// create API ignored CREATE_SUSPENDED the thread is already running and
// this is a harmless no-op.
// SAFETY: `pi.hThread` is the just-created, still-owned main-thread
// handle; `ResumeThread` only adjusts its suspend count.
unsafe {
ResumeThread(pi.hThread);
// `--wait-for-debugger`: block (no timeout) until an external
// debugger attaches to the real sandboxed PID, then clear only
// wxc-exec's own CREATE_SUSPENDED hold β€” see `debugger_wait` module
// docs for why this leaves the debugger's own attach-time freeze in
// place (so a plain `g` is enough, no `~0 m` needed). Unreachable in
// practice: `validate()` rejects `--wait-for-debugger` for this
// backend before `spawn_base` runs (BaseContainer cannot guarantee
// CREATE_SUSPENDED is honored on every OS build). Handled here
// anyway so a direct/test caller that bypasses `validate()` still
// fails closed instead of silently violating the suspend guarantee.
if request.wait_for_debugger {
if let Err(e) = crate::debugger_wait::wait_for_debugger_then_resume(
pi.hProcess,
pi.hThread,
pi.dwProcessId,
logger,
) {
let _ = writeln!(
logger,
"Error: --wait-for-debugger failed ({e}); the sandboxed child has \
been terminated."
);
// `wait_for_debugger_then_resume` already terminated the
// child on failure; reap it and tear down the same
// sandbox/proxy state the job-setup-failure path above does.
unsafe {
let _ = WaitForSingleObject(pi.hProcess, u32::MAX);
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
}
if request.lifecycle.destroy_on_exit {
run_sandbox_cleanup(
&identity,
&sid_string,
request.policy.network_proxy.is_enabled(),
logger,
);
sandbox_tracking::unregister_ctrl_c_cleanup();
}
self.proxy_coordinator.stop(logger);

const WAIT_FOR_DEBUGGER_FAILED_MSG: &str =
"--wait-for-debugger failed while waiting for a debugger to attach; \
the sandboxed child was terminated.";
return Err(ScriptResponse {
exit_code: -1,
error_message: WAIT_FOR_DEBUGGER_FAILED_MSG.to_string(),
standard_err: WAIT_FOR_DEBUGGER_FAILED_MSG.to_string(),
extended_error: format!("wait_for_debugger_then_resume failed: {e}"),
failure_phase: FailurePhase::LaunchFailed,
..Default::default()
});
}
Comment on lines +1181 to +1223

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The prefer_base_container fix made this branch dead code β€” worth deleting it now.

Following on from the thread above: routing --wait-for-debugger away from BaseContainer via prefer_base_container = !request.wait_for_debugger is the right fix, and the validate() rejection is a sensible backstop for direct callers. Together they mean this branch in spawn_base can no longer execute. spawn_base is private, its only caller runs self.validate(request) first, and validate rejects the flag unconditionally at line 1293 β€” so request.wait_for_debugger is provably false by the time control reaches line 1181.

That leaves ~50 lines of unreachable code carrying its own error strings (WAIT_FOR_DEBUGGER_FAILED_MSG), its own teardown sequence, and its own ScriptResponse construction, all of which have to be kept in step with the job-setup cleanup path next to them and with the AppContainer copy of the same logic. Defense-in-depth is cheap when it's an assertion; it's less cheap when it's a full parallel error path that no test can reach and no reader can verify.

Suggest deleting the branch and keeping the unconditional resume. If you'd rather keep a tripwire for the "someone refactors spawn_base's caller and drops the validate call" scenario, a one-liner does the same job without the maintenance surface:

debug_assert!(
    !request.wait_for_debugger,
    "wait_for_debugger must be rejected by validate() before reaching spawn_base"
);

Non-blocking β€” but the two guards you added are strictly better than this branch, and leaving all three in place obscures which one is actually load-bearing.

} else {
// The child was created suspended; now that it is in the job object (so
// every descendant it spawns is captured), resume its main thread. If the
// create API ignored CREATE_SUSPENDED the thread is already running and
// this is a harmless no-op.
// SAFETY: `pi.hThread` is the just-created, still-owned main-thread
// handle; `ResumeThread` only adjusts its suspend count.
unsafe {
ResumeThread(pi.hThread);
}
}

// Hand ownership to the caller via `BaseChild`, which performs
Expand Down Expand Up @@ -1226,6 +1281,25 @@ struct BaseChild {

impl SandboxBackend for BaseContainerRunner {
fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> {
// `Experimental_CreateProcessInSandbox` is not guaranteed to honor
// CREATE_SUSPENDED on every OS build (see the creation-flags comment
// above), so this backend cannot guarantee the sandboxed process has
// run no code before a debugger attaches -- the core contract
// `--wait-for-debugger` promises. The dispatcher already routes
// `--wait-for-debugger` requests to an AppContainer tier
// automatically (its `CreateProcess*` contract always honors
// CREATE_SUSPENDED); this only fails closed for direct callers of
// this backend.
if request.wait_for_debugger {
return Err(ScriptResponse::error(
"--wait-for-debugger is not supported by the BaseContainer backend: \
Experimental_CreateProcessInSandbox is not guaranteed to honor \
CREATE_SUSPENDED on every OS build, so it cannot guarantee the \
sandboxed process has run no code before a debugger attaches. Use \
the AppContainer tier instead (the dispatcher does this \
automatically for --wait-for-debugger).",
));
}
// deniedPaths reaches the OS via the SandboxSpec `fs_deny` field, honored
// only when the OS advertises SANDBOX_CAP_FS_DENY. The dispatcher only
// routes deny here when supported; fail closed for direct callers.
Expand Down Expand Up @@ -1790,6 +1864,27 @@ mod tests {
);
}

#[test]
fn validate_runner_rejects_wait_for_debugger() {
// BaseContainer's `Experimental_CreateProcessInSandbox` cannot
// guarantee CREATE_SUSPENDED is honored on every OS build, so this
// backend must fail closed for `--wait-for-debugger` rather than
// silently risk running code before a debugger attaches. The
// dispatcher already routes this flag to an AppContainer tier (see
// `dispatcher::tests::wait_for_debugger_skips_base_container_even_when_usable`);
// this covers a direct/test caller that bypasses the dispatcher.
let runner = BaseContainerRunner::new();
let request = ExecutionRequest {
wait_for_debugger: true,
..ExecutionRequest::default()
};

let err = runner
.validate(&request)
.expect_err("--wait-for-debugger must be rejected by the BaseContainer backend");
assert!(err.error_message.contains("wait-for-debugger"));
}

#[test]
fn validate_runner_rejects_allowed_hosts() {
let runner = BaseContainerRunner::new();
Expand Down
134 changes: 134 additions & 0 deletions src/backends/appcontainer/common/src/debugger_wait.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//! `--wait-for-debugger` support.
//!
//! The mechanism: the runners create the sandboxed child suspended and
//! assign it to the job object exactly as before, but instead of calling
//! `ResumeThread` immediately, they hand off to
//! [`wait_for_debugger_then_resume`], which:
//!
//! 1. Logs the PID and blocks β€” with **no timeout** β€” polling
//! `CheckRemoteDebuggerPresent` on the child's process handle. The main
//! thread has not run any code yet β€” not even its own loader
//! initialization, so no dependent DLL past the main image is mapped β€”
//! so nothing here races an operator who takes an arbitrarily long time
//! to attach.
//! 2. The instant a debugger attaches, calls `ResumeThread` exactly once.
//!
//! Why step 2 doesn't let the target run away before the operator sets
//! breakpoints: attaching a debugger (`DebugActiveProcess`) synthesizes an
//! initial batch of debug events (process/thread/module state) that stay
//! *outstanding* β€” and every thread stays frozen β€” until the debugger
//! itself acknowledges them via `ContinueDebugEvent`/`g`. That freeze is a
//! second, independent contribution to the same suspend-count field
//! `CREATE_SUSPENDED` used, which is why attaching used to leave the count
//! at 2 (our 1 + the debugger's 1) and required an operator-side `~0 m`
//! before `g`. By calling `ResumeThread` ourselves the moment attach is
//! detected, we cancel only *our own* contribution β€” the debugger's
//! independent freeze is still holding the thread β€” so all the operator
//! needs is a plain `g`. Every breakpoint set beforehand is still
//! necessarily a deferred/pending one (nothing but the main image has
//! loaded), so it resolves normally once the target actually starts running
//! after `g`.
//!
//! Once resumed this way, `wxc-exec` falls into its normal (already
//! unbounded when `script_timeout` is 0) wait for the child to exit β€” no
//! special-casing there. Ctrl-C during the attach-wait is handled by the
//! existing `sandbox_tracking` console-control handler (registered well
//! before this point), which runs on its own OS thread independent of this
//! poll loop.
//!
//! Failure handling: a `CheckRemoteDebuggerPresent` error (e.g. the process
//! handle going bad because the suspended child was killed externally while
//! we were polling) is a permanent condition, not "no debugger yet" β€” retrying
//! it forever would hang `wxc-exec` indefinitely. Likewise a failing
//! `ResumeThread` after attach must not be reported as success. Both cases
//! terminate the child and return an error, mirroring
//! `SpawnedChild::resume`'s fail-closed behavior; the caller only needs to
//! tear down its own sandbox resources and surface the error.

use std::fmt::Write as _;
use std::time::Duration;

use windows::Win32::Foundation::{GetLastError, HANDLE};
use windows::Win32::System::Diagnostics::Debug::CheckRemoteDebuggerPresent;
use windows::Win32::System::Threading::{ResumeThread, TerminateProcess};
use windows_core::BOOL;

use wxc_common::error::WxcError;
use wxc_common::logger::Logger;

/// How often to poll `CheckRemoteDebuggerPresent` while waiting.
const POLL_INTERVAL: Duration = Duration::from_millis(100);

/// Block (no timeout) until a debugger attaches to `process`, then resume
/// `thread` exactly once β€” cancelling only the `CREATE_SUSPENDED` increment
/// `wxc-exec` itself added, leaving the debugger's own attach-time freeze in
/// place so the operator's plain `g` is what actually starts it running.
///
/// On failure the child is terminated (fail closed) before returning
/// [`WxcError::Process`]; the caller only needs to tear down its own sandbox
/// resources and surface the error.
///
/// # Safety
/// `process` and `thread` must be valid, still-open handles to the just
/// created, still-suspended child for the duration of this call; the caller
/// retains ownership (this function does not close either handle).
pub fn wait_for_debugger_then_resume(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The wait loop's state machine can't be tested without a real debugger and a real process.

wait_for_debugger_then_resume reaches straight through to CheckRemoteDebuggerPresent, ResumeThread, TerminateProcess, and std::thread::sleep, all inline. That bakes four decisions into a function whose actual logic β€” poll until attached, resume exactly once, terminate and fail closed on error β€” is pure state machine and would be trivially testable on its own.

The practical cost is visible in this PR: the branch coverage that exists is on the guards around the feature (dispatcher::tests::wait_for_debugger_skips_base_container_even_when_usable, validate_runner_rejects_wait_for_debugger), because those are the only parts that could be tested without an OS-level fixture. The behavior that actually implements the feature has none. The two hang findings on this PR are both single-branch defects that a mocked loop would have caught immediately.

A closure-based seam is enough here β€” no trait or new module needed:

fn wait_loop(
    mut is_attached: impl FnMut() -> Result<bool, WxcError>,
    mut is_alive:    impl FnMut() -> bool,
    mut tick:        impl FnMut(),
) -> Result<(), WxcError> { /* the loop, and only the loop */ }

The Win32 version passes real closures; tests pass canned sequences ([false, false, true], is_alive flipping to false mid-wait, is_attached returning Err) and a no-op tick, so the whole matrix runs in microseconds with no child process at all. The public wait_for_debugger_then_resume signature doesn't have to change.

I'd treat this as a should-fix rather than a merge blocker β€” but it's the difference between the two hang fixes being verified and being merely believed, so it's worth doing in the same change rather than as follow-up.

process: HANDLE,
thread: HANDLE,
pid: u32,
logger: &mut Logger,
) -> Result<(), WxcError> {
let banner = format!("Process suspended, PID: {pid}. Attach a debugger to continue.");
let _ = writeln!(logger, "{banner}");
eprintln!("{banner}");

loop {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ctrl-C during the attach-wait orphans the suspended child permanently.

The module doc (lines 41-44) says this case is covered:

Ctrl-C during the attach-wait is handled by the existing sandbox_tracking console-control handler (registered well before this point), which runs on its own OS thread independent of this poll loop.

That handler runs, but it doesn't touch the child. Tracing what it actually does:

  • dacl_ctrl_handler (src/core/wxc/src/main.rs) emits cancellation telemetry, drops the parked DaclManager to restore host ACEs, and cancels an in-flight audit trace. It never terminates the child.
  • sandbox_tracking::cleanup_sandbox handles identity/SID/profile teardown, not process termination.
  • The job object is never given JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE β€” job_object.rs only ever calls SetInformationJobObject(JobObjectBasicUIRestrictions) β€” so closing the job handle at exit doesn't reap it either.

Net effect: wxc-exec exits, the child's handles close, and a process that has never executed a single instruction (not even loader init) is left suspended at suspend-count 1 with no thread to resume it. It survives until someone finds it in Task Manager. This is squarely on the happy path for the feature's intended use β€” an operator starts the wait, changes their mind, and hits Ctrl-C. Each abandoned attempt leaks another one, and because they're pre-loader-init they're easy to miss.

Two options, both cheap:

  1. Kill from the signal path. JobObject::terminate() already exists (job_object.rs:282). Park the job the same way the DaclManager is parked, and call it from dacl_ctrl_handler before yielding to the default handler. This mirrors the existing register_ctrl_c_cleanup/unregister_ctrl_c_cleanup shape in sandbox_tracking.rs:250-264, so it fits the established pattern.
  2. Let the OS do it. Add JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE to the job's extended limits when wait_for_debugger is set. Fewer moving parts and it also covers the TerminateProcess-on-parent case that the comment block in main.rs notes bypasses every handler β€” but it needs a check that it doesn't disturb the normal (non-debugger) lifetime expectations.

I'd lean toward (2) gated on the flag, with (1) as the fallback if kill-on-close turns out to interact badly with the existing teardown ordering.

Worth pairing with a regression test: launch with --wait-for-debugger, send Ctrl-C during the wait, assert the PID printed in the Process suspended, PID: <pid> banner no longer exists.

Either way, please also fix the lines 41-44 comment β€” right now it documents this as solved, which is what would stop the next reader from noticing.

let mut present = BOOL(0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The loop has no cooperative exit β€” every way out is external.

Flagging this mostly to note it's not fully addressed by the other two loop findings, in case it looks redundant. Adding a liveness check fixes the dead-child case, and killing the child from the Ctrl-C path fixes the orphan case β€” but both work by destroying the thing the loop is polling. The loop itself still has exactly one voluntary exit (present == true); nothing can ask it to stop.

Two places that matters:

  • Tests. A test that wants to verify "loop is still waiting after N ticks, now cancel it" has no way to end the loop except by tearing down a real process. This compounds with the seam comment above β€” a closure seam plus a cancellation predicate is what makes the wait cheaply testable, and each is half a fix on its own.
  • Any future non-signal shutdown. If the wait ever needs to end for a reason that isn't Ctrl-C and isn't child death β€” a timeout mode, a supervisor asking wxc-exec to stand down β€” there's no place to put it.

An Option<&AtomicBool> checked alongside the poll, or a should_cancel: impl FnMut() -> bool if you take the closure-seam route, covers both. Low priority relative to the two hangs β€” file it under "do this if you're already refactoring the loop for the seam", not as separate work.

// SAFETY: `process` is a valid, open handle for the duration of this
// call (caller contract); `present` is a valid out-param on the stack.
let checked = unsafe { CheckRemoteDebuggerPresent(process, &mut present) };
match checked {
Ok(()) if present.as_bool() => break,
Ok(()) => std::thread::sleep(POLL_INTERVAL),
Comment on lines +93 to +95

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the suspended child dies before a debugger attaches, this loop spins forever.

The Err arm is doing the fail-closed work here, and its comment states the trigger:

The handle went bad (e.g. the still-suspended child was terminated externally) -- this can never resolve on its own, so fail closed instead of polling forever.

But the handle can't go bad. wait_for_debugger_then_resume's own # Safety contract requires the caller to keep process open for the entire call, and that's exactly what keeps the kernel object alive after the process dies. A HANDLE to a terminated process is still a perfectly valid handle β€” the object stays referenced until the last handle closes. CheckRemoteDebuggerPresent succeeds on it and reports present = FALSE.

So on external termination the loop takes the Ok(()) arm, sleeps 100 ms, and repeats β€” forever. The Err arm never fires for the one scenario it was written for, and wxc-exec hangs instead of failing closed. Worth noting the module doc at lines 47-53 builds the whole failure-handling story on this premise, so the design intent is right and only the mechanism is wrong.

WaitForSingleObject fixes it and folds the sleep into the same call, so the loop gets a liveness check for free:

match checked {
    Ok(()) if present.as_bool() => break,
    Ok(()) => {
        // Doubles as the poll interval: returns early only if the child
        // died, which can never resolve into an attach.
        if unsafe { WaitForSingleObject(process, POLL_INTERVAL.as_millis() as u32) }
            == WAIT_OBJECT_0
        {
            return Err(WxcError::Process(format!(
                "PID {pid} exited before a debugger attached"
            )));
        }
    }
    Err(e) => { /* unchanged */ }
}

Keep the Err arm β€” it's still correct for genuine API failures, it just isn't the path that catches a dead child.

This is also the cheapest of the findings to lock in with a test: spawn any suspended process, TerminateProcess it, and assert the wait returns an error rather than hanging (with a test timeout, so a regression fails loudly instead of wedging CI).

Err(e) => {
// The handle went bad (e.g. the still-suspended child was
// terminated externally) -- this can never resolve on its
// own, so fail closed instead of polling forever.
// SAFETY: `process` is the caller's still-owned handle;
// terminating it before returning matches the fail-closed
// contract every other launch-failure path in the runners
// follows.
unsafe {
let _ = TerminateProcess(process, u32::MAX);
}
return Err(WxcError::Process(format!(
"CheckRemoteDebuggerPresent failed while waiting for a debugger to \
attach to PID {pid}: {e}"
)));
}
}
}

// SAFETY: `thread` is the caller's still-owned, still-suspended main
// thread handle; ResumeThread only adjusts its suspend count. This
// cancels exactly the one increment wxc-exec added via CREATE_SUSPENDED
// β€” the debugger's own attach-time freeze (a separate contribution to
// the same counter) is unaffected and keeps the thread from actually
// running until the operator issues `g`.
let resumed = unsafe { ResumeThread(thread) };
if resumed == u32::MAX {
let err = unsafe { GetLastError() };
// SAFETY: same fail-closed contract as above.
unsafe {
let _ = TerminateProcess(process, u32::MAX);
}
return Err(WxcError::Process(format!(
"ResumeThread failed after debugger attach for PID {pid}: {err:?}"
)));
}
let _ = writeln!(logger, "Process started, PID: {pid}.");
Ok(())
Comment on lines +121 to +133

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The feature's entire runtime behavior ships with zero automated coverage.

The PR's validation section lists build, fmt, clippy, and "built wxc-exec.exe and confirmed it runs". The tests that did land cover backend selection β€” that the dispatcher skips BaseContainer and that validate rejects it. Nothing exercises what happens after the process is created suspended.

Concretely, all of these would ship green today:

  • ResumeThread called twice, or not at all (the suspend-count reasoning in the module doc is the subtlest part of this PR and is entirely unguarded)
  • the terminate-on-error path silently not terminating, leaving the fail-closed contract unmet
  • the flag not reaching ExecutionRequest from the CLI at all
  • the parsed default flipping from false to true

That last pair is the part I'd push on regardless of how the seam question (see the testability comment above) is resolved, because it needs no Win32 fixture at all:

  • a main.rs CLI test asserting --wait-for-debugger present/absent maps to request.wait_for_debugger true/false β€” main.rs already has CLI parsing tests to extend
  • a config_parser.rs assertion that the default is false β€” the parser suite already constructs ExecutionRequest and now silently carries this field

Both are a few lines each and lock in the plumbing half of the feature.

For the Win32 half, if the loop gets a closure seam then the interesting cases (not-attached-then-attached, resume failure, check failure, child dies mid-wait) are all cheap unit tests. Without a seam, the fallback is a wxc_e2e_tests case that launches a trivial AppContainer process with the flag, asserts the thread's suspend count is non-zero, attaches with DebugActiveProcess from the test itself, and asserts the process then runs to completion β€” real coverage, but meaningfully more machinery than the seam route, which is part of why the seam is worth adding first.

}
37 changes: 36 additions & 1 deletion src/backends/appcontainer/common/src/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,19 @@ fn select_backend_with_fallback(
),
DispatchError,
> {
let decision = fallback_detector::detect(&request.policy, /*prefer_bc=*/ true)?;
// `--wait-for-debugger` needs a suspend point the Win32 `CreateProcess`
// contract actually guarantees. BaseContainer's
// `Experimental_CreateProcessInSandbox` may silently ignore
// CREATE_SUSPENDED on some OS builds (see `base_container_runner`'s
// creation-flags comment) -- which would let the sandboxed process start
// running before a debugger attaches, violating the feature's core
// guarantee. Skip Tier 1 whenever `wait_for_debugger` is requested so
// selection falls through to an AppContainer tier (2/3), whose
// `CreateProcess*` calls always honor CREATE_SUSPENDED per the documented
// Win32 contract. `BaseContainerRunner::validate` also rejects the flag
// directly, as defense-in-depth for callers that bypass this dispatcher.
let prefer_base_container = !request.wait_for_debugger;
let decision = fallback_detector::detect(&request.policy, prefer_base_container)?;

let (backend, dacl_manager): (SelectedBackend, Option<DaclManager>) = match decision.tier {
IsolationTier::BaseContainer => {
Expand Down Expand Up @@ -829,6 +841,29 @@ mod tests {
assert!(matches!(d.tier, IsolationTier::AppContainerDacl));
}

#[test]
fn wait_for_debugger_skips_base_container_even_when_usable() {
// `--wait-for-debugger` must never land on BaseContainer:
// `Experimental_CreateProcessInSandbox` cannot guarantee
// CREATE_SUSPENDED is honored on every OS build, which would violate
// the flag's core "no code has run yet" guarantee. Force
// BaseContainer *usable* (bypassing real host capability) so this
// exercises the `prefer_base_container` gate itself, not host
// variance -- without the guard, Tier 1 would be selected (see
// `dispatch_t1_naturally_selected_when_bc_usable` /
// `select_backend_t1_builds_base_container_no_dacl`).
let _g = BcUsableGuard::set(true);
let mut req = test_request(empty_policy());
req.wait_for_debugger = true;
let (backend, _dacl, tier, _warnings) =
select_backend_with_fallback(&req).expect("selection should succeed");
assert!(
!matches!(tier, IsolationTier::BaseContainer),
"wait_for_debugger must never select BaseContainer, got {tier:?}"
);
assert!(matches!(backend, SelectedBackend::AppContainer(_)));
}

// -------------------------------------------------------------------
// Streaming dispatch (`select_backend_with_fallback` /
// `spawn_with_fallback`) β€” issue #643.
Expand Down
2 changes: 2 additions & 0 deletions src/backends/appcontainer/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pub mod appcontainer_runner;
#[cfg(target_os = "windows")]
pub mod base_container_runner;
#[cfg(target_os = "windows")]
pub mod debugger_wait;
#[cfg(target_os = "windows")]
pub mod dispatcher;
#[cfg(target_os = "windows")]
pub mod fallback_detector;
Expand Down
Loading
Loading