You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The Linux and MacOS backends had no standard default environment. This PR gives all three the same schema-0.9 contract the Windows process container got in #1120, implemented per-backend:
An omitted process.env gets a default block of PATH, HOME, and TERM.
An explicitly empty process.env stays empty, distinct from omitted.
A supplied process.env is used verbatim.
inheritDefaultEnv layers a supplied environment over the default, with a caller entry replacing the same-named default rather than duplicating it.
PATH is per-OS: Seatbelt keeps its existing value, Linux gets the sbin directories. HOME is the resolved working directory, since the child runs as the launching uid and that user's real home is not reachable under the sandbox's filesystem policy. When no working directory resolves, LXC and Bubblewrap fall back to /tmp (both provide it writable and Bubblewrap mounts a fresh tmpfs); Seatbelt leaves HOME unset, because its profile is deny-default and grants /private/tmp only under guiAccess, so a shared fallback would be either unwritable or a preplant target.
Version gate
The gate is a normalized ExecutionRequest::default_env_compatibility, set by each exact-contract adapter (0.6/0.7/0.8 legacy, 0.9+ default block) alongside network_enforcement_compatibility. It is deliberately not read back from source_contract, which is external-JSON attribution that the typed SDK builder clears — so a typed SDK request built against an exact pre-0.9 contract keeps pre-0.9 behavior.
The Windows process container now reads the same field. It previously applied the 0.9 env states at every version; below 0.9 an explicitly empty process.env again resolves to the user profile block rather than to an empty one.
Scope
Behavior below 0.9 is unchanged on all four backends. IsolationSession and WSLc are not covered and are tracked separately.
Seatbelt's resolution lives in a new host-agnostic default_env module, since seatbelt_runner is target_os = "macos" and would otherwise be untested off a Mac.
The Unix backends had no default environment: Bubblewrap supplied none at all,
so `PATH` came from the shell's compiled-in default and a nightly run failed on
RHEL 10 where that value omits the `sbin` directories. LXC and Seatbelt each
had their own unrelated behavior.
Gives all three the same schema-0.9 contract the Windows process container got
in #1120, implemented per-backend:
* An omitted `process.env` gets a default block of `PATH`, `HOME`, and `TERM`.
* An explicitly empty `process.env` stays empty, distinct from omitted.
* A supplied `process.env` is used verbatim.
* `inheritDefaultEnv` layers a supplied environment over the default, with a
caller entry replacing the same-named default rather than duplicating it.
`PATH` is per-OS: Seatbelt keeps its existing value, Linux gets the `sbin`
directories. `HOME` is the resolved working directory (else `/tmp`), since the
child runs as the launching uid and that user's real home is not reachable
under the sandbox's filesystem policy.
Behavior below 0.9 is unchanged on all three. IsolationSession and WSLc are not
covered and are tracked separately.
Seatbelt's resolution lives in a new host-agnostic `default_env` module, since
`seatbelt_runner` is `target_os = "macos"` and would otherwise be untested off
a Mac.
Tests
* Unit tests per backend for all four states plus the sub-0.9 passthrough
* `run_bwrap_environment_test.sh` and `run_lxc_env_09_test.sh`, wired into the
suite runners; `run_seatbelt_environment_test.sh` extended
* cargo fmt --all -- --check
* cargo clippy --workspace --all-targets -- -D warnings
* cargo clippy for x86_64-unknown-linux-gnu and aarch64-apple-darwin
* cargo test for the touched crates
* node scripts/versioning/validate-configs.js
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Conflict resolutions:
- lxc_runner.rs: keep resolved_env(request) for the exec environment on top of
main's ContainerFirewall/plan_network changes; take main's trimmed comments.
- config_parser.rs: differential corpus inventory is (379, 355, 14) -- main's
converged baseline plus the twelve 0.9 inheritDefaultEnv fixtures.
- version-specific-parser-migration-inventory.md: match the new counts.
- lxc-backend.md: keep main's rewrite and re-apply the 0.9 default-env section.
- run_lxc_all_tests.sh: keep both new suites.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The merge resolution rewrote the file with LF, which made it diff as 180
changed lines instead of the 18 that were actually added. main stores this
file with CRLF; match it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Default HOME can disagree with the actual working directory, and Seatbelt still injects or overwrites PWD for supposedly empty or verbatim environments.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Standardizes schema-0.9 default environment handling across LXC, Bubblewrap, and Seatbelt.
Changes:
Adds backend-specific PATH, HOME, and TERM defaults with inheritance handling.
Adds unit and end-to-end coverage for all environment modes.
Documents the new behavior and updates parser fixtures.
The three 0.9 environment suites asserted PATH=[] for the two cases that
supply no PATH. A shell started without a PATH assigns its own compiled-in
fallback, so \ reports the shell's default rather than what MXC
passed and is never empty. On Debian that fallback is byte-for-byte the
Linux default block, and lxc-attach injects the same string into every
process it attaches, so neither its presence nor its absence proved
anything. macOS /bin/sh fabricates TERM=dumb the same way.
Assert HOME and TERM instead -- neither is fabricated, and both would be
set had the default block been applied. That resolved_env is exactly empty
is already asserted directly by the per-backend unit tests, which read the
environment MXC builds rather than the child's view of it.
Also make the host-leak assertions real: the 0.9 fixtures never echoed
MXC_LEAK_PROBE, so grepping the output for its value could never match and
the assertions passed vacuously. The fixtures now print it, which also
distinguishes a shell-fabricated TERM from an inherited one. The inherit
fixtures override TERM with vt100 rather than dumb so the override is
distinguishable from the macOS shell default.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
process.cwd is allowed to be relative. For cwd: "work", this sets HOME=work, while build_args subsequently changes into work; inside the child, $HOME then resolves to work/work rather than the working directory promised by this default. Derive HOME from the effective absolute sandbox cwd (or pass the final chdir path into this resolver), and cover the relative-cwd case.
let home = request
.resolved_working_directory()
.map(|dir| dir.path.to_string())
.unwrap_or_else(|| FALLBACK_HOME.to_string());
src/backends/lxc/common/src/lxc_runner.rs:55
A relative process.cwd is copied into HOME unchanged, but lxc-attach later executes cd work before starting the workload. Consequently $HOME=work resolves to a nested work/work path from the child's actual cwd instead of naming that cwd as documented. Resolve the effective absolute container cwd once and use it for both the attach command and this default.
let home = request
.resolved_working_directory()
.map(|dir| dir.path.to_string())
.unwrap_or_else(|| FALLBACK_HOME.to_string());
This uses the generic, unnormalized resolver, while seatbelt_runner separately expands ~ and (for the open path) anchors relative cwd values. Thus a cwd such as work or a ~/... policy fallback can leave HOME relative/unexpanded even though the child runs in a different normalized directory, breaking tools that use $HOME. Pass the runner's effective normalized cwd into environment resolution so both values agree.
let home = request
.resolved_working_directory()
.map(|dir| dir.path.to_string())
.unwrap_or_else(|| FALLBACK_HOME.to_string());
A relative process.cwd is copied directly into HOME. After Bubblewrap applies --chdir work, HOME=work resolves relative to the new directory (for example, as work/work) instead of naming the directory where the child actually runs. Resolve the cwd to the same absolute target used by --chdir, or reject relative cwd values before constructing the default block. src/backends/lxc/common/src/lxc_runner.rs:66
start_directory can return a relative process.cwd, so this sets HOME to that raw relative string. The attach wrapper first changes into that directory; from inside the child, HOME=work then refers to a nested work/work path rather than the actual working directory. Resolve the cwd against the same container-side base used by attach_run (or set HOME after the wrapper's cd) before building the default environment. src/backends/seatbelt/common/src/seatbelt_runner.rs:244
A relative process.cwd is passed through unchanged here. Command::current_dir("work") resolves it against the launcher directory, but the child receives HOME=work; after the chdir, that value resolves to <launcher>/work/work, not the directory where the child is running. This also makes the exec path differ from spawn_open, which already anchors relative paths. Anchor the resolved cwd once before using it for both current_dir and resolved_env.
A relative process.cwd is copied directly into HOME, but --chdir changes the process directory before launch. For cwd: "work", HOME=work then resolves beneath the new directory rather than naming it, contrary to this function's contract. Normalize the actual target to an absolute path or reject relative cwd before building the environment. src/backends/lxc/common/src/lxc_runner.rs:66
Using the raw relative cwd as HOME does not make HOME name the directory entered by the wrapper. With cwd: "work", the wrapper first changes into work, after which HOME=work refers to a nested work/work path. Resolve the container-relative target to an absolute path, or reject relative cwd before constructing the default environment. src/backends/seatbelt/common/src/seatbelt_runner.rs:243
A relative process.cwd makes the new HOME point somewhere different from the actual working directory. For example, cwd: "work" is resolved by Command::current_dir against the launcher directory, but this passes HOME=work; once the child is in <launcher>/work, HOME resolves as <launcher>/work/work. Anchor the value before passing it to resolved_env, or reject relative cwd before applying this contract.
The final Seatbelt environment is still not verbatim for schema 0.9: after apply_clean_environment handles env: [] or a supplied block, this unconditionally adds PWD and overwrites any caller-provided PWD. That contradicts the stated empty/verbatim contract. Either gate this legacy workaround for the 0.9 explicit modes or explicitly reserve and document PWD and adjust the contract/tests.
let cwd = resolved_cwd.unwrap_or_else(|| UNRESOLVED_WORKING_DIRECTORY.to_string());
command.current_dir(&cwd);
command.env("PWD", &cwd);
The reason will be displayed to describe this comment to others. Learn more.
🟢 Approval recommended
The implementation consistently preserves version boundaries, applies the four-state contract across all scoped backends, and includes focused unit, integration, and documentation updates.
A relative process.cwd is copied directly into HOME. Bubblewrap resolves --chdir within the sandbox, but the resulting child cwd is absolute; leaving HOME relative means shell ~ expansion points below that cwd instead of back to it. Normalize the sandbox start directory to the absolute path used by --chdir before using it as HOME, and test a relative cwd. src/backends/lxc/common/src/lxc_runner.rs:66
start_directory may be relative, so this sets a relative HOME even though lxc-attach resolves cd "$1" from its container start directory. For cwd: "work", the process starts in /work but HOME=work, causing ~ to resolve as /work/work. Resolve the in-container cwd to the same absolute path used by the attach command before deriving HOME, and add a relative-cwd test. src/backends/seatbelt/common/src/seatbelt_runner.rs:243
A relative process.cwd is passed directly as the default HOME. The exec path resolves that cwd relative to the launcher when current_dir runs, so the child starts in an absolute directory but receives a relative HOME (for example, cwd: "work" yields HOME=work, making ~ resolve below the cwd rather than to it). Anchor the cwd with the existing absolute_working_directory helper before passing it to both environment resolution and current_dir, and cover the relative-cwd case. docs/seatbelt/seatbelt-backend.md:442
The reason will be displayed to describe this comment to others. Learn more.
The generated profile is deny default and only grants /private/tmp under guiAccess, so without GUI access a workload that writes anything under $HOME gets EPERM, and with it HOME points at shared host /private/tmp, where another local user can preplant .npmrc/.gitconfig/.ssh/config that the sandbox then reads.
Suggest a per-run mode-0700 directory granted explicitly in the profile, plus a non-GUI test that writes under $HOME.
The reason will be displayed to describe this comment to others. Learn more.
filter_map(split_once('=')) silently drops an entry with no =, so inheritDefaultEnv: true with ["FEATURE_FLAG"] loses it with no diagnostic and the path at :594 passes that same entry straight through. The two modes disagree on identical input. Either reject malformed entries at validation with a clear message or handle them identically in both paths.
Update SDK documentation for non-empty default environment blocks
src/core/mxc_engine/src/policy.rs:899
The newly asserted non-empty defaults make the public Rust SDK documentation stale: SandboxRequest::inherit_default_env still says it is equivalent to set_env on LXC, Bubblewrap, and Seatbelt (policy.rs:677-678). That is now false from 0.9 and can cause callers to omit required inheritance. Update that API documentation to describe these new default blocks.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
Cross-platform process-launch semantics and version compatibility require final human validation despite comprehensive coverage.
Review effort: Balanced Findings: None
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📖 Description
The Linux and MacOS backends had no standard default environment. This PR gives all three the same schema-0.9 contract the Windows process container got in #1120, implemented per-backend:
process.envgets a default block ofPATH,HOME, andTERM.process.envstays empty, distinct from omitted.process.envis used verbatim.inheritDefaultEnvlayers a supplied environment over the default, with a caller entry replacing the same-named default rather than duplicating it.PATHis per-OS: Seatbelt keeps its existing value, Linux gets thesbindirectories.HOMEis the resolved working directory, since the child runs as the launching uid and that user's real home is not reachable under the sandbox's filesystem policy. When no working directory resolves, LXC and Bubblewrap fall back to/tmp(both provide it writable and Bubblewrap mounts a fresh tmpfs); Seatbelt leavesHOMEunset, because its profile is deny-default and grants/private/tmponly underguiAccess, so a shared fallback would be either unwritable or a preplant target.Version gate
The gate is a normalized
ExecutionRequest::default_env_compatibility, set by each exact-contract adapter (0.6/0.7/0.8 legacy, 0.9+ default block) alongsidenetwork_enforcement_compatibility. It is deliberately not read back fromsource_contract, which is external-JSON attribution that the typed SDK builder clears — so a typed SDK request built against an exact pre-0.9 contract keeps pre-0.9 behavior.The Windows process container now reads the same field. It previously applied the 0.9 env states at every version; below 0.9 an explicitly empty
process.envagain resolves to the user profile block rather than to an empty one.Scope
Behavior below 0.9 is unchanged on all four backends. IsolationSession and WSLc are not covered and are tracked separately.
Seatbelt's resolution lives in a new host-agnostic
default_envmodule, sinceseatbelt_runneristarget_os = "macos"and would otherwise be untested off a Mac.🔗 References
Resolves #1153
Related:
inheritDefaultEnvon IsolationSession and WSLc, still open🔍 Validation
run_bwrap_environment_test.shandrun_lxc_env_09_test.sh, wired into the suite runners;run_seatbelt_environment_test.shextended✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes (see docs/pull-requests.md)📋 Issue Type
Microsoft Reviewers: Open in CodeFlow