Skip to content
Closed
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
25 changes: 18 additions & 7 deletions sdk/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Pick `0.7.0-alpha` for new code on any supported platform.
| --- | --- | --- | --- |
| Windows 11 24H2+ (verified on 25H2) | `processcontainer` | `windows_sandbox`, `wslc`, `microvm`, `isolation_session` | `processcontainer`: 26100 (24H2)<br>`isolation_session`: 26300.8553 ([Insider Preview](https://learn.microsoft.com/en-us/windows-insider/release-notes/experimental/preview-build-26300-8553)) |
| Linux x64 / ARM64 | `bubblewrap` | `lxc` | β€” |
| macOS ARM64 (schema `0.7.0-alpha`+) | `seatbelt` | `apple_container` (config only) | β€” |
| macOS ARM64 (schema `0.7.0-alpha`+) | `seatbelt` | `apple_container` | `apple_container`: macOS 26 on Apple silicon |

The default `processcontainer`, `bubblewrap`, `lxc`, and `seatbelt` backends work out of the box. **Experimental backends** (`windows_sandbox`, `wslc`, `microvm`, `isolation_session`, `hyperlight`, `apple_container`) require `{ experimental: true }` in `SandboxSpawnOptions` when you spawn β€” see [Choosing a Backend](#choosing-a-backend).

Expand Down Expand Up @@ -201,7 +201,7 @@ console.log(result.stdout);
| `microvm` | `microvm` | Windows | Experimental | [`docs/nanvix-microvm/nanvix.md`](https://github.com/microsoft/mxc/blob/main/docs/nanvix-microvm/nanvix.md) β€” MicroVM via NanVix on Windows Hypervisor Platform |
| `wslc` | (concrete only) | Windows | Experimental | [`docs/wsl/wsl-container-getting-started.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wsl-container-getting-started.md) |
| `isolation_session` | (concrete only) | Windows | Experimental | [`docs/isolation-session/oneshot.md`](https://github.com/microsoft/mxc/blob/main/docs/isolation-session/oneshot.md) |
| `apple_container` | (concrete only) | macOS ARM64 | Experimental, config only | β€” |
| `apple_container` | (concrete only) | macOS 26 ARM64 | Experimental | β€” |

Experimental backends require `{ experimental: true }` in `SandboxSpawnOptions`:

Expand All @@ -216,15 +216,26 @@ schema version `0.8.0-alpha`:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

issue (non-blocking): This now documents Apple Container as executable, but docs/schema.md still says the backend is config-only and runtime execution fails closed with unsupported_containment (the apple_container row around line 278). Could we update that canonical schema reference in this PR as well? The two docs currently give contradictory guidance about whether this example can run.

```typescript
const config = createAppleContainerConfig(
{ version: '0.8.0-alpha' },
{
version: '0.8.0-alpha',
timeoutMs: 30_000,
network: { allowOutbound: true },
},
{ image: 'docker.io/library/alpine:3.23', cpuCount: 2, memoryMb: 1024 },
);
config.process!.commandLine = 'echo hello';
config.process!.commandLine = 'printf "hello from "; uname -srm';

const child = spawnSandboxFromConfig(config, { experimental: true });
child.onExit(({ exitCode }) => {
console.log(`Apple Container exited with ${exitCode}`);
});
```

This increment exposes and validates the configuration contract only.
`spawnSandboxFromConfig(config, { experimental: true })` currently fails with
`unsupported_containment` before creating Apple Container resources.
Apple Container must be installed at `/usr/local/bin/container`, its service
must already be running, and the installed CLI must match the version qualified
by MXC. The backend runs Linux images rather than macOS executables. A
default-block network policy additionally requires the digest-qualified MXC
guest-init image to be installed locally.

Backend-specific tuning lives on the returned `ContainerConfig`. The full set of fields per backend is in the JSON schemas β€” they're the source of truth:

Expand Down
5 changes: 3 additions & 2 deletions sdk/node/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,9 @@ export function createConfigFromPolicy(
/**
* Creates an experimental Apple Container config for macOS.
*
* Runtime execution is not implemented yet; spawning the returned config
* currently fails with `unsupported_containment` before creating resources.
* The returned config runs a Linux OCI image through the separately installed
* Apple Container runtime. Pass `{ experimental: true }` to
* `spawnSandboxFromConfig`.
*/
export function createAppleContainerConfig(
policy: SandboxPolicy,
Expand Down
110 changes: 100 additions & 10 deletions src/backends/apple_container/common/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ pub enum PolicyError {
Plan(#[from] PlanError),
#[error("{0}")]
Resource(#[from] ResourceError),
#[error("failed to canonicalize Apple Container mount path {path:?}: {source}")]
#[error("failed to canonicalize Apple Container policy path {path:?}: {source}")]
Canonicalize {
path: String,
#[source]
Expand Down Expand Up @@ -190,17 +190,26 @@ fn build_mounts(request: &ExecutionRequest) -> Result<Vec<MountPlan>, PolicyErro
] {
for requested in paths {
let requested_path = Path::new(requested);
if !requested_path.is_absolute() {
let guest_path = normalize_absolute(requested_path).ok_or_else(|| {
rejected(format!(
"Apple Container mount path {requested:?} must be absolute and normalized"
))
})?;
let guest_text = guest_path.to_str().ok_or_else(|| {
rejected(format!(
"Apple Container mount path {requested:?} is not valid UTF-8"
))
})?;
if guest_text.contains(',') {
return Err(rejected(format!(
"Apple Container mount path {requested:?} must be absolute"
"Apple Container mount path {guest_text:?} contains ',' and cannot be represented safely"
)));
}
let canonical = std::fs::canonicalize(requested_path).map_err(|source| {
PolicyError::Canonicalize {
let canonical =
std::fs::canonicalize(&guest_path).map_err(|source| PolicyError::Canonicalize {
path: requested.clone(),
source,
}
})?;
})?;
let canonical_text = canonical.to_str().ok_or_else(|| {
rejected(format!(
"Apple Container mount path {requested:?} resolves to a non-UTF-8 path"
Expand All @@ -221,7 +230,7 @@ fn build_mounts(request: &ExecutionRequest) -> Result<Vec<MountPlan>, PolicyErro
"Apple Container has {conflict} mount destination {canonical:?}"
)));
}
mounts.push(MountPlan::new(&canonical, &canonical, access)?);
mounts.push(MountPlan::new(&canonical, guest_path, access)?);
}
}

Expand All @@ -231,18 +240,55 @@ fn build_mounts(request: &ExecutionRequest) -> Result<Vec<MountPlan>, PolicyErro
"Apple Container denied path {denied:?} must be an absolute normalized path"
))
})?;
let denied =
resolve_existing_prefix(&denied).map_err(|source| PolicyError::Canonicalize {
path: denied.to_string_lossy().into_owned(),
source,
})?;
if destinations
.keys()
.any(|mapped| denied == *mapped || denied.starts_with(mapped))
.any(|mapped| denied.starts_with(mapped) || mapped.starts_with(&denied))
{
return Err(rejected(format!(
"Apple Container cannot deny {denied:?} because it is within a mapped path"
"Apple Container cannot combine denied path {denied:?} with an overlapping mapped path"
)));
}
}
Ok(mounts)
}

fn resolve_existing_prefix(path: &Path) -> std::io::Result<PathBuf> {
let mut prefix = path;
let mut absent_tail = Vec::new();
loop {
match std::fs::canonicalize(prefix) {
Ok(mut canonical) => {
for component in absent_tail.iter().rev() {
canonical.push(component);
}
return Ok(canonical);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match std::fs::symlink_metadata(prefix) {
Ok(_) => return Err(error),
Err(metadata_error)
if metadata_error.kind() == std::io::ErrorKind::NotFound => {}
Err(metadata_error) => return Err(metadata_error),
}
let Some(name) = prefix.file_name() else {
return Err(error);
};
absent_tail.push(name.to_os_string());
let Some(parent) = prefix.parent() else {
return Err(error);
};
prefix = parent;
}
Err(error) => return Err(error),
}
}
}

fn normalize_absolute(path: &Path) -> Option<PathBuf> {
if !path.is_absolute() {
return None;
Expand Down Expand Up @@ -326,4 +372,48 @@ mod tests {
fn accepts_default_deny_policy() {
assert!(validate_policy(&request()).is_ok());
}

#[test]
#[cfg(target_os = "macos")]
fn rejects_denied_and_mapped_path_overlap_in_either_direction() {
let root =
std::env::temp_dir().join(format!("mxc-apple-policy-overlap-{}", std::process::id()));
let child = root.join("child");
std::fs::create_dir_all(&child).unwrap();

for (mapped, denied) in [(&root, &child), (&child, &root)] {
let mut request = request();
request.policy.readwrite_paths = vec![mapped.to_string_lossy().into_owned()];
request.policy.denied_paths = vec![denied.to_string_lossy().into_owned()];

let error = build_run_plan(&request, None).unwrap_err();

assert!(
error.to_string().contains("overlapping mapped path"),
"{error}"
);
}
std::fs::remove_dir_all(root).unwrap();
}

#[test]
#[cfg(target_os = "macos")]
fn mount_preserves_requested_guest_path_after_host_canonicalization() {
let root = std::env::temp_dir().join(format!(
"mxc-apple-policy-destination-{}",
std::process::id()
));
std::fs::create_dir_all(&root).unwrap();
let mut request = request();
request.policy.readwrite_paths = vec![root.to_string_lossy().into_owned()];

let plan = build_run_plan(&request, None).unwrap();

assert_eq!(plan.mounts[0].guest_path, root);
assert_eq!(
plan.mounts[0].host_path,
std::fs::canonicalize(&root).unwrap()
);
std::fs::remove_dir_all(root).unwrap();
}
}
19 changes: 12 additions & 7 deletions src/backends/apple_container/common/src/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ impl RecoveryGuard {
create_private_directory(&directory)?;
let path = directory.join(format!("{}.json", plan.ownership_token.as_str()));
let temporary_path = directory.join(format!(".{}.tmp", plan.ownership_token.as_str()));
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary_path)?;
let mut options = fs::OpenOptions::new();
options.read(true).write(true).create_new(true).mode(0o600);
#[cfg(target_os = "macos")]
options.custom_flags(libc::O_EXLOCK);
let mut file = options.open(&temporary_path)?;
#[cfg(not(target_os = "macos"))]
lock_exclusive(&file, false)?;
let write_result = (|| {
serde_json::to_writer(&mut file, &RecoveryRecord::from_plan(plan, container_hint))?;
Expand Down Expand Up @@ -137,11 +137,16 @@ pub fn stale_recoveries() -> Result<Vec<StaleRecovery>, RecoveryError> {
if !matches!(extension, Some("json" | "tmp")) {
continue;
}
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
record_count += 1;
if record_count > MAX_RECOVERY_RECORDS {
return Err(RecoveryError::TooManyRecords);
}
if !fs::symlink_metadata(&path)?.file_type().is_file() {
if !metadata.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("recovery record {path:?} is not a regular file"),
Expand Down
48 changes: 46 additions & 2 deletions src/backends/apple_container/common/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,52 @@ fn cleanup_resource(
return Ok(());
}
}
run_checked(runner, &delete_command(resource))?;
Ok(())
match run_checked(runner, &delete_command(resource)) {
Ok(_) => Ok(()),
Err(delete_error) if resource.name.kind() == ResourceKind::Container && !stop_container => {
// The foreground CLI can exit a few milliseconds before Apple's
// persisted container state becomes terminal. Re-prove ownership
// before escalating to the same bounded stop/delete path used for
// timeout cleanup.
match verify_ownership(runner, resource) {
Ok(false) => return Ok(()),
Ok(true) => {}
Err(probe_error) => {
return Err(CliError::Command(format!(
"{delete_error}; ownership re-check failed: {probe_error}"
)));
}
}
let stop_result = run_checked(runner, &stop_container_command(resource));
match verify_ownership(runner, resource) {
Ok(false) => return Ok(()),
Ok(true) => {}
Err(probe_error) => {
let stop_detail = stop_result
.err()
.map(|error| format!("; fallback stop failed: {error}"))
.unwrap_or_default();
return Err(CliError::Command(format!(
"{delete_error}{stop_detail}; ownership check after fallback stop failed: \
{probe_error}"
)));
}
}
run_checked(runner, &delete_command(resource))
.map(|_| ())
.map_err(|retry_error| {
let stop_detail = stop_result
.err()
.map(|error| format!("; fallback stop failed: {error}"))
.unwrap_or_default();
CliError::Command(format!(
"{delete_error}{stop_detail}; delete after fallback stop also failed: \
{retry_error}"
))
})
}
Err(error) => Err(error),
}
}

struct SecureEnvironmentFile {
Expand Down
Loading
Loading