Skip to content

fix(appcontainer): default sandbox cwd to a granted path instead of NULL - #674

Open
caarlos0 wants to merge 5 commits into
microsoft:mainfrom
caarlos0:pwd-appcontainer
Open

fix(appcontainer): default sandbox cwd to a granted path instead of NULL#674
caarlos0 wants to merge 5 commits into
microsoft:mainfrom
caarlos0:pwd-appcontainer

Conversation

@caarlos0

@caarlos0 caarlos0 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

📖 Description

Sandboxed shells were starting on C:\ instead of the intended working directory on the Windows AppContainer + DACL (and BaseContainer) backends.

Root cause: when process.cwd was empty, both Windows runners passed a NULL current directory to CreateProcessW, so the child inherited the host process's cwd. Under a deny-by-default AppContainer token that directory is often unopenable, and the kernel then silently resets the child to the drive root (C:\) instead of failing the launch. This surfaced most via the in-process Rust SDK, whose host cwd is the caller's process directory rather than a granted path (the Node SDK happened to inherit the executor's cwd, which is usually the granted workspace).

Fix: mirror the macOS Seatbelt backend, which already avoids this trap. Add ExecutionRequest::resolved_working_directory() — explicit working_directory wins, else the first readwrite path, else the first readonly path — and use it in both Windows runners. An unset cwd now defaults to a policy-granted path (first readwrite/readonly) instead of NULL. The resolver only picks the path; it does not verify the directory exists, so an explicit-but-ungranted (or missing) cwd still fails loudly — parity with Seatbelt — rather than silently landing on C:\.

Scope note: only the AppContainer/BaseContainer family was affected. Seatbelt already had an equivalent resolver; the micro-VM backends (NanVix/Hyperlight) reject a working directory by design; the Linux/WSL backends fall back to / / the container root.

🔗 References

🔍 Validation

  • New unit tests for resolved_working_directory() (explicit wins, first readwrite, first readonly, none) — cargo test -p wxc_common passes.
  • New end-to-end regression tests in e2e_processcontainer_characterization.rs, mirroring the Seatbelt relative-file probe: processcontainer_runs_in_first_readwrite_path_when_process_cwd_empty (empty process.cwd, two granted directories, relative output file; the launcher's cwd is the second granted path so a NULL-cwd regression is distinguishable) and processcontainer_honors_explicit_process_cwd. Like the rest of that suite they skip unless a host-prepped Windows lane sets MXC_E2E_HOST_PREPPED=1, so they never red-fail on incapable CI. The isolation tier is not independently selectable from a config (it is derived from host capability; MXC_FORCE_TIER is cfg(test)-only), so the tests cover whichever tier the lane resolves to — both tiers are covered by running on a BaseContainer-capable and a downlevel host.
  • cargo check / cargo clippy -- -D warnings on appcontainer_common and wxc_e2e_tests --tests for x86_64-pc-windows-msvc — clean.
  • cargo check -p mxc-sdk and cargo fmt --all -- --check — clean.
  • Merged latest main (no conflicts); full PR CI green across all Windows/Linux/macOS build, lint, and SDK jobs.

✅ Checklist

📋 Issue Type

  • Bug fix
  • Feature
  • Task

When `process.cwd` was empty, the AppContainer and BaseContainer runners
passed a NULL current directory to `CreateProcessW`, so the child inherited
the host process's cwd. Under a deny-by-default AppContainer token that
directory is often unopenable, and the kernel then silently resets the
child to the drive root (`C:\`) instead of failing the launch — surfacing
as sandboxed shells starting on `C:\` rather than the working directory
(notably via the in-process Rust SDK, whose host cwd is the caller's, not a
granted path).

Mirror the Seatbelt backend's resolver: add
`ExecutionRequest::resolved_working_directory()` (explicit `working_directory`
wins, else the first `readwrite` path, else the first `readonly` path) and use
it in both Windows runners so an unset cwd defaults to a directory the sandbox
token can actually open.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a38efe5c-db9c-4ad7-a1c6-537b6cc3df4f
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 19:30
@caarlos0
caarlos0 requested a review from a team as a code owner July 23, 2026 19:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes an AppContainer/BaseContainer Windows behavior where an unset working directory resulted in passing NULL to CreateProcessW, causing the child process to inherit the host cwd and (when inaccessible under a deny-by-default token) silently start at C:\. It adds a shared resolver on ExecutionRequest to choose a policy-granted cwd when the request doesn’t specify one, and uses it in both Windows runners.

Changes:

  • Add ExecutionRequest::resolved_working_directory() to derive an appropriate cwd (explicit wins; else first readwrite path; else first readonly path; else None).
  • Use the resolver in both AppContainerScriptRunner and BaseContainerRunner when building the CreateProcess* working-directory argument.
  • Update the schema documentation example to mention the new defaulting behavior.
Show a summary per file
File Description
src/core/wxc_common/src/models.rs Adds ExecutionRequest::resolved_working_directory() and unit tests covering precedence and empty-policy behavior.
src/backends/appcontainer/common/src/base_container_runner.rs Uses the resolved working directory when passing the cwd pointer to Experimental_CreateProcessInSandbox.
src/backends/appcontainer/common/src/appcontainer_runner.rs Uses the resolved working directory when passing the cwd pointer to CreateProcessW.
docs/schema.md Updates the config example comment for process.cwd to reflect defaulting behavior when omitted.

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment thread src/core/wxc_common/src/models.rs Outdated
Comment thread docs/schema.md Outdated
The resolver only picks the first policy path; it does not verify the path
exists or is a directory, so drop the "guaranteed to open" wording. Also
note that the schema example's defaulting is backend-specific rather than a
universal rule.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a38efe5c-db9c-4ad7-a1c6-537b6cc3df4f
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Comment thread src/core/wxc_common/src/models.rs Outdated
Comment thread src/backends/appcontainer/common/src/appcontainer_runner.rs Outdated
Condense the `resolved_working_directory` doc comment and replace the
duplicated rationale at both runner call sites with a one-line pointer to
the function, keeping the explanation in a single place.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a38efe5c-db9c-4ad7-a1c6-537b6cc3df4f
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 24, 2026 14:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Medium

Comment thread src/backends/appcontainer/common/src/appcontainer_runner.rs
The resolver unit tests only cover path selection, so they would still pass
if either Windows runner ignored `resolved_working_directory()` and passed a
NULL current directory to the launch API. Add the missing end-to-end coverage,
mirroring the Seatbelt relative-file probe:

* `processcontainer_runs_in_first_readwrite_path_when_process_cwd_empty` —
  empty `process.cwd`, two granted directories, and a relative output file;
  the launcher cwd is the *second* granted path, so a NULL-cwd regression is
  openable by the token and lands the probe there instead.
* `processcontainer_honors_explicit_process_cwd` — an explicit cwd wins over
  the policy-path fallback (the fallback path is listed first).

The tier (BaseContainer vs AppContainer+DACL) is not independently selectable
from a config: the dispatcher derives it from host capability and the
MXC_FORCE_TIER seam is cfg(test)-only, so it has no effect on the production
wxc-exec.exe. The tests exercise whichever tier the prepared lane resolves to;
both tiers are covered by running the suite on a BaseContainer-capable and a
downlevel host. Documented in the module header, which also no longer claims
cwd is out of scope.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 367b5917-43d7-4288-9fce-d28ed9711268
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 20:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

Copilot AI review requested due to automatic review settings July 30, 2026 21:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Comments suppressed due to low confidence (1)

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

  • The fallback assumes an allow-list entry is a directory, but filesystem policy paths may also name files: DaclManager::apply_one explicitly handles file paths by making their ACE non-inheritable (filesystem_dacl.rs:447-453), and config validation does not require directories (config_parser.rs:337-354). Thus a valid config whose first read-write/read-only entry is a file now passes that file as lpCurrentDirectory, causing process creation to fail. Please select only usable directory entries and define the fallback when none exists, rather than choosing an arbitrary policy path.
        self.policy
            .readwrite_paths
            .first()
            .or_else(|| self.policy.readonly_paths.first())
            .map(String::as_str)
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Medium

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants