diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 744e5b0d9..bc837d7be 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -162,7 +162,9 @@ Common consequences of this default: - `/opt` and `/usr/local` tooling is not on PATH; list either path under `readonlyPaths` if the script depends on it. - `working_directory` must live under the baseline or a policy path — a - `cwd` of `~/project` without a matching `readonlyPaths` entry will fail. + `cwd` of `/home/alice/project` without a matching `readonlyPaths` entry + will fail. From `0.9.0-alpha` it must also be absolute: `--chdir` does not + expand `~`. - DNS works on systemd-resolved, NetworkManager, and resolvconf hosts because the corresponding `/run/...` directories are bound. The common symlink targets *outside* `/run` are covered too: `/var/run/...`-routed diff --git a/docs/isolation-session/oneshot.md b/docs/isolation-session/oneshot.md index 7d7e8b0e2..4c8cd1302 100644 --- a/docs/isolation-session/oneshot.md +++ b/docs/isolation-session/oneshot.md @@ -216,7 +216,8 @@ versions and stating that the bindings must be regenerated. gated by `--experimental`. - `process.commandLine` (the script command, wrapped via `cmd.exe /c "..."` — the same pattern the LXC runner uses with `/bin/sh -c`). -- `process.cwd` (working directory inside the session). +- `process.cwd` (working directory inside the session; must be an absolute + Windows path). - `process.env` (environment variables forwarded via the OS-side `IsoSessionProcessOptions`). - `process.timeout` (forwarded to the OS-side per-process timeout @@ -244,7 +245,7 @@ the rationale for each disposition, and the error mapping live in | Field | one-shot disposition | |---|---| | `process.commandLine` | **honored** (required) | -| `process.cwd` / `process.env` / `process.timeout` | **honored** | +| `process.cwd` / `process.env` / `process.timeout` | **honored** — `cwd` must be an absolute Windows path | | `filesystem.{readwritePaths,readonlyPaths,deniedPaths}` | rejected — no host-folder-sharing primitive | | `network` — directional all-allow (`egress.default`, `ingress.default`, and `ingress.hostLoopback` all `allow`, no rules) | **required** | | `network` — legacy fields, absent, empty, restrictive, mixed, rule-bearing, or proxy-bearing | rejected | diff --git a/docs/isolation-session/state-aware-rust.md b/docs/isolation-session/state-aware-rust.md index cee34727c..84c788bb1 100644 --- a/docs/isolation-session/state-aware-rust.md +++ b/docs/isolation-session/state-aware-rust.md @@ -349,7 +349,8 @@ also structurally refused as `malformed_request`. - `process.commandLine` — required for one-shot and for state-aware exec; rejected structurally at non-exec state-aware phases. - `process.cwd`, `process.env`, `process.timeout` — optional in both modes, - honoured per-process (each exec receives its own block). + honoured per-process (each exec receives its own block). A supplied `cwd` + must be an absolute Windows path. ### Policy fields and mode parity diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index 183aad28c..c44360fd3 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -87,6 +87,14 @@ Note the required field lxc. Any combination that lxc-create supports. +### Working directory + +`process.cwd` is applied inside the container, so it names a path in the +container's filesystem rather than the host's. From schema `0.9.0-alpha` on, a +set value must be an absolute Linux path; earlier versions resolve a relative +value against the `lxc-exec` process's directory. An empty value keeps the +container default. + ### Preventing environment variables from leaking into LXC If `process.env` has a value, `lxc-attach` is run with `--clear-env` so host diff --git a/docs/schema.md b/docs/schema.md index 7d3f803ef..a0cc629ac 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -127,10 +127,8 @@ that can be executed independently. "process": { "commandLine": "python app.py", // Required: command to execute - "cwd": "C:\\workspace", // Working directory (optional; when omitted each - // backend substitutes a granted directory rather - // than inheriting the launcher's — see - // "Working Directory" below) + "cwd": "C:\\workspace", // Working directory (optional; must be absolute on + // 0.9.0-alpha+ — see "Working Directory" below) "env": ["MY_VAR=value"], // Omitted: backend default; supplied: used verbatim "inheritDefaultEnv": true, // Layer env on the backend default (0.9.0-alpha+) "timeout": 30000 // Timeout in ms (0 = no timeout) @@ -269,7 +267,24 @@ that can be executed independently. `process.cwd` is optional. When it is set, it is passed to the backend verbatim — an unusable value fails the launch rather than being silently -replaced. When it is **omitted**, backends do not simply inherit the launcher's +replaced. The one exception is the WSL Container backend's one-shot surface, +which reads the value as a Windows host path and translates it to the matching +in-container path (`C:\workspace` → `/mnt/c/workspace`); a value with no such +equivalent is rejected. + +**From schema `0.9.0-alpha` on, a set value must be absolute**, since a relative +path would resolve against the launching process's working directory. Absolute +means absolute for the target the path reaches, not for the host MXC runs on: + +| Backend | Absolute form | +|---------|---------------| +| Windows ProcessContainer / Windows Sandbox / IsolationSession | `C:\workspace`, `C:/workspace`, or a UNC path. `C:workspace` and `\workspace` are relative. | +| WSL Container | One-shot: the Windows host path (`C:\workspace`), which the backend translates. State-aware `exec`: the in-container path (`/workspace`). | +| Seatbelt (macOS) | `/workspace`. A `~` path is rejected: MXC would expand it from the launching host's `HOME`, and falls back to a literal `~` when that is unset. | +| LXC / Bubblewrap | `/workspace`. A `~` path is rejected; it is not expanded. | +| MicroVM (NanVix) / Hyperlight | n/a — these backends reject any working directory. | + +When `process.cwd` is **omitted**, backends do not simply inherit the launcher's working directory: under a deny-by-default sandbox that directory is usually unreadable, and the result ranges from a confusing silent relocation (Windows restarts the child at the drive root) to `getcwd()` errors on the child's @@ -280,7 +295,7 @@ use: |---------|----------------------------------------| | Windows ProcessContainer (AppContainer / BaseContainer) | First `readwritePaths` entry that is an existing directory, else the first such `readonlyPaths` entry, else the system drive root (`%SystemDrive%\`). Never `NULL`. | | Seatbelt (macOS) | Same precedence, with `~` expanded as the profile expands it; falls back to `/`. | -| LXC / WSL Container | The container root — see [`docs/lxc-support/lxc-backend.md`](lxc-support/lxc-backend.md). | +| LXC / WSL Container | The container root. | | MicroVM (NanVix) / Hyperlight | Not applicable — these backends reject a working directory outright. | Policy entries that are blank, name a file, or do not exist yet are skipped: diff --git a/docs/seatbelt/seatbelt-backend.md b/docs/seatbelt/seatbelt-backend.md index b261d082e..21ca34da1 100644 --- a/docs/seatbelt/seatbelt-backend.md +++ b/docs/seatbelt/seatbelt-backend.md @@ -429,8 +429,12 @@ paths are. `PWD` is exported to the resolved directory. Both launch methods apply it: `exec` sets it on the child process, while `open` performs the `cd` and the `PWD` export inside the generated helper script, -since Terminal would otherwise start the workload in its own directory. A -relative `cwd` is resolved against the MXC process's directory on both paths. +since Terminal would otherwise start the workload in its own directory. + +From schema `0.9.0-alpha` on, a set `cwd` must be absolute. A `~` path is +rejected too — it is expanded from the launching host's `HOME`, and an unset +`HOME` leaves a literal `~` that `chdir`s against the launcher. Earlier +versions resolve a relative value against the MXC process's directory. **Note:** `getcwd()` only succeeds when the directory *itself* is readable under the profile. An out-of-policy `cwd` makes callers that resolve relative paths (`git`, Python's diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index 7751744b0..eba845f43 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -1586,8 +1586,9 @@ Exact structural errors carry the full field path and source coordinates before the successful request is constructed. Semantic errors remain backend-owned. `validate_exec_common` is a free function in `validator.rs` that checks cross-backend per-phase invariants -(e.g., `request.script_code` non-empty); other phases have no cross-backend common -checks today and skip directly to the backend's `validate_` hook. +(`request.script_code` non-empty, and an absolute `process.cwd` from `0.9.0-alpha`); +other phases have no cross-backend common checks today and skip directly to the +backend's `validate_` hook. Helper functions for handle-validation, envelope wrapping, and empty-envelope construction are mechanical and elided. The executor's outer driver @@ -1630,7 +1631,7 @@ shapes. |---|---|---| | SDK (TypeScript) | Recognised `containment` (provision); branded `SandboxId` (other phases); required cross-backend fields (`process.commandLine` for exec); typed config shape (autocompletion + compile-time check) | Thrown at the call site, before any subprocess runs | | MXC parser (Rust) | Exact registered version and closed request root; required phase fields; phase-inappropriate, unknown, and recursively unknown experimental fields | `error.code: malformed_request`, `unsupported_phase`, `unsupported_containment` | -| MXC dispatch common (Rust) | Cross-backend per-phase invariants (e.g., `validate_exec_common` checks `process.commandLine` non-empty) | `error.code: malformed_request`, `policy_validation` | +| MXC dispatch common (Rust) | Cross-backend per-phase invariants (`validate_exec_common` checks `process.commandLine` non-empty and an absolute `process.cwd`) | `error.code: malformed_request`, `policy_validation` | | Backend `validate_` hooks (Rust) | Per-backend per-phase invariants: config field values, cross-cutting policy honor (per the matrix in §10.3), id format checks beyond prefix matching | `error.code: policy_validation`, `malformed_id`, `stale_id`, `backend_error`, `backend_unavailable` | The native CLI template form is resolved before these layers: a trailing diff --git a/docs/wsl/wsl-container-getting-started.md b/docs/wsl/wsl-container-getting-started.md index 37438918f..2f6e16c44 100644 --- a/docs/wsl/wsl-container-getting-started.md +++ b/docs/wsl/wsl-container-getting-started.md @@ -415,6 +415,17 @@ Paths in `filesystem.readwritePaths` and `filesystem.readonlyPaths` are mounted into the container. Host path `C:\workspace` becomes `/mnt/c/workspace` inside the container. +### `process.cwd` + +One-shot takes a **Windows host path** and translates it the way mounts are +translated: `C:\workspace` runs the process in `/mnt/c/workspace`. A value that +cannot be translated — a UNC path, or an in-container path such as +`/workspace` — is **rejected**. It was previously dropped without a diagnostic, +leaving the process in the container's default directory. + +State-aware `exec` is the reverse: it takes the in-container path (`/workspace`) +directly and rejects anything that does not start with `/`. + ### `ui` is not supported A `ui` section is **rejected** — the backend has no mechanism to enforce UI diff --git a/schemas/dev/mxc-config.schema.0.9.0-alpha.json b/schemas/dev/mxc-config.schema.0.9.0-alpha.json index d9077b9b7..9a576c4cb 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-alpha.json +++ b/schemas/dev/mxc-config.schema.0.9.0-alpha.json @@ -1010,7 +1010,7 @@ "description": "The non-empty command line to execute." }, "cwd": { - "description": "Optional working directory.", + "description": "Optional working directory. A supplied value must be absolute for the target the path reaches — `C:\\workspace` or a UNC path for the Windows backends, `/workspace` for the Unix ones. WSL Container reads it as a Windows host path one-shot and as an in-container path on a state-aware `exec`. A relative path — including a `~` path, which MXC would have to expand from the launching host's environment — is rejected because it would resolve against the launching process's working directory.", "type": "string" }, "env": { diff --git a/schemas/dev/mxc-config.schema.0.9.0-dev.json b/schemas/dev/mxc-config.schema.0.9.0-dev.json index 207226586..5d6eb23ed 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.9.0-dev.json @@ -743,7 +743,7 @@ ] }, "cwd": { - "description": "Working directory for the process. When omitted, backends substitute a directory the sandbox can use rather than inheriting the launcher's cwd: Windows ProcessContainer picks the first `readwritePaths` entry that is an existing directory, else the first such `readonlyPaths` entry, else the system drive root; Seatbelt applies the same precedence with a `/` fallback; LXC/WSL use the container root; NanVix and Hyperlight reject a working directory outright. See `docs/schema.md` (\"Working Directory\").", + "description": "Working directory for the process. When omitted, backends substitute a directory the sandbox can use rather than inheriting the launcher's cwd: Windows ProcessContainer picks the first `readwritePaths` entry that is an existing directory, else the first such `readonlyPaths` entry, else the system drive root; Seatbelt applies the same precedence with a `/` fallback; LXC/WSL use the container root; NanVix and Hyperlight reject a working directory outright. See `docs/schema.md` (\"Working Directory\").\n\nFrom schema 0.9 on, a supplied value must be absolute for the target the path reaches — `C:\\workspace` or a UNC path for the Windows backends, `/workspace` for the Unix ones. WSL Container reads it as a Windows host path one-shot and as an in-container path on a state-aware `exec`. A relative path — including a `~` path, which MXC would have to expand from the launching host's environment — is rejected because it would resolve against the launching process's working directory.", "type": [ "string", "null" diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs index 2ff811a9f..883ccde0d 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs @@ -36,7 +36,10 @@ public SandboxRequest(SandboxPolicy policy, string command) [JsonPropertyName("containerName")] public string? ContainerName { get; set; } - /// An optional initial working directory. + /// + /// An optional initial working directory. Must be absolute from schema + /// 0.9.0-alpha on. + /// [JsonPropertyName("workingDirectory")] public string? WorkingDirectory { get; set; } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs index b5b4ef919..c36c19801 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs @@ -223,7 +223,7 @@ public class StateAwarePhaseOptions /// Process and schema options for a state-aware exec phase. public class StateAwareExecOptions : StateAwarePhaseOptions { - /// Working directory inside the sandbox. + /// Working directory inside the sandbox. Must be absolute. public string? WorkingDirectory { get; set; } /// Environment variables encoded as KEY=VALUE strings. diff --git a/sdk/node/README.md b/sdk/node/README.md index 64a612d71..f2f7bfc99 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -512,9 +512,9 @@ You must set `config.process!.commandLine = '…'` before calling `spawnSandboxF No `network` field → no network. No `readwritePaths` → process can't write `%TEMP%`. No `ui` → no GUI. Use the discovery helpers to compose a sensible baseline. -### `process.cwd` doesn't grant filesystem access +### `process.cwd` -Setting `cwd` (or the `workingDirectory` argument) does **not** add that path to the policy. Add it to `readonlyPaths` / `readwritePaths` explicitly. +Setting `cwd` (or the `workingDirectory` argument) does **not** add that path to the policy. Add it to `readonlyPaths` / `readwritePaths` explicitly. On `0.9.0-alpha` it must also be absolute — `C:\workspace` for the Windows backends, `/workspace` for the Unix ones. WSL Container splits the two: a one-shot run takes the Windows host path (`C:\workspace`, which the backend translates), while a state-aware `exec` takes the in-container path (`/workspace`). For Windows ProcessContainer requests using schema `0.9.0-alpha`, `processContainer.filesystem.enumeratePaths` permits directory listing without diff --git a/sdk/node/src/generated/v0_9_0_alpha/wire.ts b/sdk/node/src/generated/v0_9_0_alpha/wire.ts index c8fc8806c..0f9635855 100644 --- a/sdk/node/src/generated/v0_9_0_alpha/wire.ts +++ b/sdk/node/src/generated/v0_9_0_alpha/wire.ts @@ -569,7 +569,7 @@ export interface Process { */ commandLine: NonEmptyString; /** - * Optional working directory. + * Optional working directory. A supplied value must be absolute for the target the path reaches — `C:\workspace` or a UNC path for the Windows backends, `/workspace` for the Unix ones. WSL Container reads it as a Windows host path one-shot and as an in-container path on a state-aware `exec`. A relative path — including a `~` path, which MXC would have to expand from the launching host's environment — is rejected because it would resolve against the launching process's working directory. */ cwd?: string; /** diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 1be263bac..a728bf4ad 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -355,6 +355,8 @@ export interface Process { commandLine?: string | null; /** * Working directory for the process. When omitted, backends substitute a directory the sandbox can use rather than inheriting the launcher's cwd: Windows ProcessContainer picks the first `readwritePaths` entry that is an existing directory, else the first such `readonlyPaths` entry, else the system drive root; Seatbelt applies the same precedence with a `/` fallback; LXC/WSL use the container root; NanVix and Hyperlight reject a working directory outright. See `docs/schema.md` ("Working Directory"). + * + * From schema 0.9 on, a supplied value must be absolute for the target the path reaches — `C:\workspace` or a UNC path for the Windows backends, `/workspace` for the Unix ones. WSL Container reads it as a Windows host path one-shot and as an in-container path on a state-aware `exec`. A relative path — including a `~` path, which MXC would have to expand from the launching host's environment — is rejected because it would resolve against the launching process's working directory. */ cwd?: string | null; /** diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index 1454caf22..ccfbec75f 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -13,7 +13,11 @@ export interface ProcessConfig { /** Complete command line to execute (e.g., "python -c \"print('hello')\"") */ commandLine: string; - /** Working directory for the process */ + /** + * Working directory for the process. From schema `0.9.0-alpha` it must be + * absolute for the backend that receives it — see `docs/schema.md` + * ("Working Directory"). + */ cwd?: string; /** * Environment variables as KEY=VALUE strings. diff --git a/sdk/node/tests/integration/common.test.ts b/sdk/node/tests/integration/common.test.ts index 5681f8957..c24fca3ce 100644 --- a/sdk/node/tests/integration/common.test.ts +++ b/sdk/node/tests/integration/common.test.ts @@ -4,6 +4,7 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; import os from 'os'; +import { MxcError } from '@microsoft/mxc-sdk'; import { sdk, supportedVersions, @@ -104,3 +105,31 @@ for (const schemaVersion of platformVersions) { }); }); } + +// A working directory MXC cannot honour is caller-fixable, so it must reach an +// SDK caller as `policy_validation` — the classification the state-aware and +// native surfaces already give the same refusal — rather than the +// infrastructure-failure `backend_error`. The refusal happens in shared +// validation before any sandbox is created, so this needs no backend +// prerequisites and cannot run the command. +describe('Working directory (schema 0.9.0-alpha)', { + skip: !platformSupport.isSupported ? `Platform not supported: ${platformSupport.reason}` : undefined, +}, () => { + it('should reject a relative cwd as policy_validation', async () => { + await assert.rejects( + () => sdk.spawnSandboxAsync( + 'echo unreachable', + { version: '0.9.0-alpha' }, + { ...debugSpawnOptions }, + 'relative-subdir', + 'cwd-relative', + ), + (error: unknown) => { + assert.ok(error instanceof MxcError, `expected an MxcError, got ${String(error)}`); + assert.strictEqual(error.code, 'policy_validation', `got ${error.code}: ${error.message}`); + assert.match(error.message, /process\.cwd must be an absolute path/); + return true; + }, + ); + }); +}); diff --git a/sdk/node/tests/integration/wslc-e2e.test.ts b/sdk/node/tests/integration/wslc-e2e.test.ts index c89e3ff3d..893afda7b 100644 --- a/sdk/node/tests/integration/wslc-e2e.test.ts +++ b/sdk/node/tests/integration/wslc-e2e.test.ts @@ -245,4 +245,45 @@ srv.handle_request() `expected SDK-limitation message mentioning UDP; output=${combined}`, ); }); + + it('should reject a cwd that does not map into the container', { timeout: 60_000 }, async () => { + // A UNC path is absolute on Windows, so it clears the shared schema-0.9 + // check and is refused by WSLc itself: one-shot reads `process.cwd` as a + // host path it maps into the container, and a UNC path has no such + // equivalent. That refusal is not version-gated, so it must reach the SDK + // with the same `policy_validation` classification. + const policy = { + version: '0.9.0-alpha', + network: { + egress: { default: 'allow' as const }, + ingress: { default: 'allow' as const, hostLoopback: 'allow' as const }, + }, + filesystem: {}, + }; + const config = sdk.createConfigFromPolicy(policy, 'wslc'); + config.process!.commandLine = 'echo unreachable'; + config.process!.cwd = '\\\\server\\share'; + config.experimental!.wslc!.image = 'alpine:latest'; + + const { exitCode, combined } = await new Promise<{ exitCode: number; combined: string }>((resolve, reject) => { + const child = sdk.spawnSandboxFromConfig(config, { experimental: true, debug: true, usePty: false }) as ChildProcess; + let combined = ''; + const onData = (d: Buffer) => { combined += d.toString(); }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('error', reject); + child.on('close', (code: number | null) => resolve({ exitCode: code ?? -1, combined })); + }); + + assert.notStrictEqual(exitCode, 0, `expected a non-zero exit for an untranslatable cwd; output=${combined}`); + const envelope = combined + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('{')) + .map((line) => { try { return JSON.parse(line); } catch { return undefined; } }) + .find((parsed) => parsed && typeof parsed === 'object' && 'error' in parsed); + assert.ok(envelope, `expected a JSON error envelope; output=${combined}`); + assert.strictEqual(envelope.error.code, 'policy_validation', `envelope=${JSON.stringify(envelope)}`); + assert.match(envelope.error.message, /maps into the container/); + }); }); diff --git a/src/backends/windows_sandbox/lifecycle/src/error.rs b/src/backends/windows_sandbox/lifecycle/src/error.rs index 096a6025e..ed1ace925 100644 --- a/src/backends/windows_sandbox/lifecycle/src/error.rs +++ b/src/backends/windows_sandbox/lifecycle/src/error.rs @@ -72,10 +72,14 @@ impl OneShotError { /// whether a retry could ever succeed. fn failure_phase(&self) -> FailurePhase { match self { - // Non-retryable preflight: the request/config cannot be honored or a - // required host prerequisite is missing. Retrying the same input on - // the same host will not succeed. - OneShotError::SandboxUnavailable(_) | OneShotError::Policy(_) => FailurePhase::Rejected, + // The optional feature is off, so no policy change makes this host + // serve the request. The state-aware surface already reports that + // as `backend_unavailable`; keeping the phase distinct from a + // policy refusal is what lets both surfaces agree. + OneShotError::SandboxUnavailable(_) => FailurePhase::BackendUnavailable, + // Non-retryable preflight: the request cannot be honored as + // written, so the input itself has to change. + OneShotError::Policy(_) => FailurePhase::Rejected, // Launch attempt failed (incl. transient single-instance contention, // async-runtime setup, capture-proof, rendezvous wait, and the // initial guest connect) — generally worth retrying. @@ -102,18 +106,23 @@ mod tests { } #[test] - fn policy_and_prereq_errors_map_to_rejected() { - for err in [ - OneShotError::Policy("denied path in share".to_string()), - OneShotError::SandboxUnavailable("feature off".to_string()), - ] { - let resp = err.into_response(); - assert_eq!( - resp.failure_phase, - FailurePhase::Rejected, - "non-retryable preflight should map to Rejected" - ); - } + fn a_policy_refusal_maps_to_rejected() { + let resp = OneShotError::Policy("denied path in share".to_string()).into_response(); + assert_eq!( + resp.failure_phase, + FailurePhase::Rejected, + "a caller-fixable refusal should map to Rejected" + ); + } + + #[test] + fn a_disabled_optional_feature_maps_to_backend_unavailable() { + let resp = OneShotError::SandboxUnavailable("feature off".to_string()).into_response(); + assert_eq!( + resp.failure_phase, + FailurePhase::BackendUnavailable, + "a missing host prerequisite is not a policy refusal" + ); } #[test] diff --git a/src/backends/wslc/common/src/wsl_container_runner.rs b/src/backends/wslc/common/src/wsl_container_runner.rs index a3a68dbbf..6375c99fa 100644 --- a/src/backends/wslc/common/src/wsl_container_runner.rs +++ b/src/backends/wslc/common/src/wsl_container_runner.rs @@ -683,6 +683,7 @@ impl ScriptRunner for WSLContainerRunner { .into_response()); } policy::reject_unsupported_enforcement_mode(request).map_err(as_wslc_rejection)?; + container_working_directory(request)?; // The shared validator returns an untagged response; retag it so its // rejections reach SDK callers as `policy_validation` like the checks above. validate_network_policy_support(request, policy::network_policy_support()) @@ -702,10 +703,43 @@ fn as_wslc_rejection(err: MxcError) -> ScriptResponse { WslcError::Rejected(err.message).into_response() } +/// Resolve `process.cwd` to the container path the SDK is configured with, or +/// `None` when it was omitted. +/// +/// One-shot reads the value as a Windows host path and maps it to its +/// in-container mount point, so a value without a drive letter (a UNC path, +/// say) has no equivalent inside the container. This is the only place the +/// one-shot surface converts or refuses it: `validate_runner` calls it so a +/// dry-run reports the rejection before any SDK work, and +/// [`WSLContainerRunner::start_container`] calls it for the value it hands the +/// SDK. +fn container_working_directory( + request: &ExecutionRequest, +) -> Result, ScriptResponse> { + if request.working_directory.is_empty() { + return Ok(None); + } + + policy_mapping::windows_path_to_container_path(&request.working_directory) + .map(Some) + .ok_or_else(|| { + WslcError::Rejected(format!( + "WSLc: process.cwd must be a Windows drive path that maps into the container \ + (e.g. C:\\workspace -> /mnt/c/workspace), got {:?}", + request.working_directory + )) + .into_response() + }) +} + /// The first line [`WSLContainerRunner::start_container`] writes, before any /// SDK call. Tests assert its absence to prove a rejection aborted early. pub(crate) const START_CONTAINER_BANNER: &str = "[WSLC] Starting WSL Container runner"; +/// The first line [`WSLContainerRunner::init_and_load_sdk`] writes. Tests +/// assert its absence to prove a rejection ran before any SDK work. +pub(crate) const SDK_INIT_BANNER: &str = "[WSLC] COM initialized"; + /// Refuses the `lifecycle` settings the one-shot surface cannot honour. /// /// Value-based, not presence-based: the default `destroyOnExit: true` is @@ -765,7 +799,7 @@ impl WSLContainerRunner { ) } } - let _ = writeln!(logger, "[WSLC] COM initialized"); + let _ = writeln!(logger, "{SDK_INIT_BANNER}"); let sdk = match WslcSdk::shared() { Ok(s) => s, @@ -1389,6 +1423,10 @@ impl WSLContainerRunner { } }; + // Resolved before any SDK work so an untranslatable value is refused + // without a session or image being touched. + let container_cwd = container_working_directory(request)?; + // -- Init: COM + SDK + preflight -- let sdk = Self::init_and_load_sdk(logger)?; @@ -1511,22 +1549,18 @@ impl WSLContainerRunner { } let _cwd_cstr; - if !request.working_directory.is_empty() { - if let Some(container_cwd) = - policy_mapping::windows_path_to_container_path(&request.working_directory) - { - _cwd_cstr = format!("{}\0", container_cwd); - let hr = sdk.WslcSetProcessSettingsWorkingDirectory( - &mut process_settings, - _cwd_cstr.as_bytes().as_ptr() as PCSTR, - ); - if hr != S_OK { - return Err(sdk_error( - "WslcSetProcessSettingsWorkingDirectory failed", - hr, - "", - )); - } + if let Some(container_cwd) = container_cwd.as_deref() { + _cwd_cstr = format!("{container_cwd}\0"); + let hr = sdk.WslcSetProcessSettingsWorkingDirectory( + &mut process_settings, + _cwd_cstr.as_bytes().as_ptr() as PCSTR, + ); + if hr != S_OK { + return Err(sdk_error( + "WslcSetProcessSettingsWorkingDirectory failed", + hr, + "", + )); } } @@ -2550,6 +2584,110 @@ mod tests { ); } + #[test] + fn validate_runner_rejects_a_cwd_that_does_not_map_into_the_container() { + let runner = WSLContainerRunner::new(&WslcConfig::default()); + for cwd in ["\\\\server\\share", "/mnt/c/workspace", "relative"] { + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + working_directory: cwd.to_string(), + ..Default::default() + }; + let err = runner + .validate_runner(&request) + .expect_err(&format!("accepted '{cwd}'")); + assert!( + err.error_message.contains("maps into the container"), + "got: {}", + err.error_message + ); + assert_eq!( + err.failure_phase, + wxc_common::models::FailurePhase::Rejected + ); + } + } + + #[test] + fn validate_runner_accepts_a_drive_rooted_cwd() { + let runner = WSLContainerRunner::new(&WslcConfig::default()); + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + working_directory: "C:\\workspace".to_string(), + ..Default::default() + }; + assert!(runner.validate_runner(&request).is_ok()); + } + + /// The one conversion both entry points read, so neither can accept a form + /// the other refuses, or configure a different container path for it. + #[test] + fn container_working_directory_resolves_or_refuses_the_value_once() { + let resolve = |cwd: &str| { + container_working_directory(&ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + working_directory: cwd.to_string(), + ..Default::default() + }) + }; + + assert_eq!(resolve("").unwrap(), None, "an omitted cwd stays omitted"); + assert_eq!( + resolve("C:\\workspace").unwrap(), + Some("/mnt/c/workspace".to_string()) + ); + assert_eq!( + resolve("C:/workspace/src").unwrap(), + Some("/mnt/c/workspace/src".to_string()) + ); + + for cwd in ["\\\\server\\share", "/mnt/c/workspace", "relative", "C:rel"] { + let err = resolve(cwd).expect_err(&format!("accepted '{cwd}'")); + assert!( + err.error_message.contains("maps into the container"), + "got: {}", + err.error_message + ); + } + } + + /// The direct caller-built path (no `validate_runner`): the conversion runs + /// before the SDK is loaded, so an untranslatable value cannot reach COM, + /// the session, or image resolution. + #[test] + fn start_container_refuses_an_untranslatable_cwd_before_loading_the_sdk() { + let runner = WSLContainerRunner::new(&WslcConfig::default()); + let request = ExecutionRequest { + containment: wxc_common::models::ContainmentBackend::Wslc, + script_code: "echo hi".to_string(), + working_directory: "\\\\server\\share".to_string(), + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + // SAFETY: the rejection returns before any FFI call is made. + let Err(err) = + (unsafe { runner.start_container(&request, &mut logger, OutputMode::Capture) }) + else { + panic!("an untranslatable cwd must not bring a container up"); + }; + + assert!( + err.error_message.contains("maps into the container"), + "got: {}", + err.error_message + ); + assert_eq!( + err.failure_phase, + wxc_common::models::FailurePhase::Rejected + ); + assert!( + !logger.get_buffer().contains(SDK_INIT_BANNER), + "the refusal must precede SDK initialization; logger: {}", + logger.get_buffer() + ); + } + #[test] fn validate_runner_accepts_absent_ui() { let request = ExecutionRequest { diff --git a/src/core/mxc_config_contract/src/dev/stable.rs b/src/core/mxc_config_contract/src/dev/stable.rs index 83e5cc2f7..c5b765fbe 100644 --- a/src/core/mxc_config_contract/src/dev/stable.rs +++ b/src/core/mxc_config_contract/src/dev/stable.rs @@ -34,7 +34,13 @@ pub struct Telemetry { pub struct Process { /// The non-empty command line to execute. pub command_line: NonEmptyString, - /// Optional working directory. + /// Optional working directory. A supplied value must be absolute for the + /// target the path reaches — `C:\workspace` or a UNC path for the Windows + /// backends, `/workspace` for the Unix ones. WSL Container reads it as a + /// Windows host path one-shot and as an in-container path on a state-aware + /// `exec`. A relative path — including a `~` path, which MXC would have to + /// expand from the launching host's environment — is rejected because it + /// would resolve against the launching process's working directory. #[serde(default)] pub cwd: OptionalField, /// Optional environment entries encoded as `KEY=VALUE` strings. diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 900a8be06..8d5037832 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -655,7 +655,8 @@ pub struct SandboxRequest { impl SandboxRequest { /// Override the working directory the sandboxed child starts in. Left unset, - /// it defaults to the policy's resolution. + /// it defaults to the policy's resolution. Must be absolute for the target + /// backend from schema `0.9.0-alpha` on. pub fn set_working_directory(&mut self, working_directory: impl Into) -> &mut Self { self.inner.working_directory = working_directory.into(); self diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 2aa2b1ccb..e52bff3c0 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -83,6 +83,90 @@ impl ContainmentBackend { | ContainmentBackend::Vm => None, } } + + /// Path shape an explicit `process.cwd` must have for this backend on + /// `scope`. + /// + /// Linux falls an unsupported request back to LXC and macOS overrides every + /// request to Seatbelt. + pub fn working_directory_style(&self, scope: WorkingDirectoryScope) -> WorkingDirectoryStyle { + if !cfg!(target_os = "windows") { + return WorkingDirectoryStyle::Unix; + } + match self { + ContainmentBackend::ProcessContainer + | ContainmentBackend::WindowsSandbox + | ContainmentBackend::IsolationSession => WorkingDirectoryStyle::Windows, + + ContainmentBackend::Lxc + | ContainmentBackend::Bubblewrap + | ContainmentBackend::Seatbelt + | ContainmentBackend::MicroVm + | ContainmentBackend::Hyperlight + | ContainmentBackend::Vm => WorkingDirectoryStyle::Unix, + // One-shot WSLc takes a Windows *host* path and translates it into + // the container; state-aware exec takes the in-container path. + ContainmentBackend::Wslc => match scope { + WorkingDirectoryScope::OneShot => WorkingDirectoryStyle::Windows, + WorkingDirectoryScope::Exec => WorkingDirectoryStyle::Unix, + }, + } + } +} + +/// The lifecycle surface a `process.cwd` is bound for. A backend can read the +/// same field against a different target on each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkingDirectoryScope { + /// A one-shot run. + OneShot, + /// A state-aware `exec` against an already-provisioned sandbox. + Exec, +} + +/// The path shape a backend treats as absolute for `process.cwd`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkingDirectoryStyle { + /// Windows paths: `C:\dir`, `C:/dir`, or a UNC/device path. + Windows, + /// POSIX paths: `/dir` only. `~` is excluded — it expands from the + /// launching host's `HOME`, the ambient state an absolute `cwd` exists to + /// remove, and no backend can resolve it inside the sandbox. + Unix, +} + +impl WorkingDirectoryStyle { + /// Whether `path` is absolute in this style — i.e. it cannot resolve + /// against the launching process's working directory. + pub fn is_absolute(self, path: &str) -> bool { + match self { + WorkingDirectoryStyle::Windows => is_windows_absolute(path), + WorkingDirectoryStyle::Unix => path.starts_with('/'), + } + } + + /// An absolute path in this style, for error messages. + pub fn example(self) -> &'static str { + match self { + WorkingDirectoryStyle::Windows => "C:\\workspace", + WorkingDirectoryStyle::Unix => "/workspace", + } + } +} + +/// Deliberately does not use `std::path::Path::is_absolute`, which answers for +/// the *host* MXC was compiled for rather than for the target backend. +fn is_windows_absolute(path: &str) -> bool { + let bytes = path.as_bytes(); + let is_sep = |b: u8| b == b'\\' || b == b'/'; + // UNC and device paths (`\\server\share`, `\\?\C:\dir`). + if bytes.len() >= 2 && is_sep(bytes[0]) && is_sep(bytes[1]) { + return true; + } + // A drive letter is only absolute when it is also rooted: `C:dir` is + // relative to that drive's current directory, and `\dir` to its current + // drive. + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && is_sep(bytes[2]) } impl From for ContainmentBackend { @@ -1155,9 +1239,11 @@ pub enum FailurePhase { /// the VM/sandbox bring-up, or a transient resource contention (e.g. a /// single-instance backend already running). Generally worth retrying. LaunchFailed, - /// The request cannot be honored and will not succeed on a blind retry - /// without changing the input or host: a policy rejection, or a missing - /// host prerequisite (backend/runtime not installed). + /// The request cannot be honored as written and will not succeed on a blind + /// retry without changing the input: a policy rejection, or a policy this + /// host's backend cannot enforce. Caller-fixable, so it surfaces as + /// `policy_validation`. A backend that cannot serve *any* request on this + /// host is [`BackendUnavailable`](Self::BackendUnavailable) instead. Rejected, /// The launch command succeeded but the guest/sandbox infrastructure failed /// before or while running user code (agent rendezvous, channel connect, or diff --git a/src/core/wxc_common/src/script_runner.rs b/src/core/wxc_common/src/script_runner.rs index 24930eaf4..4f1002a93 100644 --- a/src/core/wxc_common/src/script_runner.rs +++ b/src/core/wxc_common/src/script_runner.rs @@ -2,7 +2,8 @@ // Licensed under the MIT License. use crate::logger::Logger; -use crate::models::{ExecutionRequest, ScriptResponse}; +use crate::models::{ExecutionRequest, FailurePhase, ScriptResponse}; +use crate::mxc_error::MxcErrorCode; use crate::validator::{validate_common, validate_network_policy_support, NetworkPolicySupport}; /// Trait for executing scripts within a containment backend. @@ -86,9 +87,16 @@ pub fn emit_backend_error_envelope(response: &ScriptResponse) { return; } + // Every caller writes `standard_err` immediately before this. A backend + // message that does not end in a newline would leave the envelope glued to + // its tail, where the line-oriented parsers the SDKs use cannot find it. + if !response.standard_err.is_empty() && !response.standard_err.ends_with('\n') { + eprintln!(); + } + let mut envelope = serde_json::json!({ "error": { - "code": "backend_error", + "code": envelope_error_code(&response.failure_phase).as_str(), "message": response.error_message, } }); @@ -101,6 +109,26 @@ pub fn emit_backend_error_envelope(response: &ScriptResponse) { } } +/// Classify a one-shot failure for the wire envelope. +/// +/// A rejected request is caller-fixable, so it carries the same +/// `policy_validation` code the native streaming and state-aware paths give it +/// (`mxc_engine`'s `map_spawn_error`). Reporting it as `backend_error` would +/// classify the same refusal differently depending on which surface the caller +/// happened to use. +/// +/// This relies on [`FailurePhase::Rejected`] meaning a policy refusal only. A +/// backend that cannot serve any request on this host must report +/// [`FailurePhase::BackendUnavailable`], or an unusable host would be +/// misreported here as an invalid caller policy. +fn envelope_error_code(phase: &FailurePhase) -> MxcErrorCode { + match phase { + FailurePhase::Rejected => MxcErrorCode::PolicyValidation, + FailurePhase::BackendUnavailable => MxcErrorCode::BackendUnavailable, + _ => MxcErrorCode::BackendError, + } +} + #[cfg(test)] mod tests { use super::get_timeout_milliseconds; @@ -145,4 +173,34 @@ mod tests { ..Default::default() }); } + + #[test] + fn a_rejected_request_is_classified_as_policy_validation() { + use crate::models::FailurePhase; + use crate::mxc_error::MxcErrorCode; + + assert_eq!( + super::envelope_error_code(&FailurePhase::Rejected), + MxcErrorCode::PolicyValidation + ); + // A host that cannot serve the backend at all is not a caller policy + // problem, and must not be reported as one. + assert_eq!( + super::envelope_error_code(&FailurePhase::BackendUnavailable), + MxcErrorCode::BackendUnavailable + ); + for phase in [ + FailurePhase::None, + FailurePhase::LaunchFailed, + FailurePhase::PostLaunchFailed, + FailurePhase::ProcessExited, + FailurePhase::Timeout, + ] { + assert_eq!( + super::envelope_error_code(&phase), + MxcErrorCode::BackendError, + "{phase:?} must keep the infrastructure classification" + ); + } + } } diff --git a/src/core/wxc_common/src/validator.rs b/src/core/wxc_common/src/validator.rs index a04b9e4d0..a5f68394f 100644 --- a/src/core/wxc_common/src/validator.rs +++ b/src/core/wxc_common/src/validator.rs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::models::{ExecutionRequest, NetworkAction, NetworkPolicy, ScriptResponse}; +use crate::models::{ + ExecutionRequest, FailurePhase, NetworkAction, NetworkPolicy, ScriptResponse, + WorkingDirectoryScope, +}; use crate::mxc_error::MxcError; /// Declares which optional network policy features a backend enforces. @@ -184,12 +187,66 @@ pub fn validate_state_aware_network_policy_support( .map_err(|response| MxcError::policy_validation(response.error_message)) } +/// First schema version (major, minor) that requires an absolute `process.cwd`. +const ABSOLUTE_CWD_MIN_SCHEMA: (u64, u64) = (0, 9); + +/// Reject a relative `process.cwd` from schema 0.9.0-alpha on, where relative +/// means "resolves against the launching process's working directory". +/// +/// Absoluteness belongs to the target the path reaches, not the host, so the +/// shape comes from [`ContainmentBackend::working_directory_style`] for +/// `scope`. Earlier schema versions keep the previous behavior. +pub fn validate_working_directory( + request: &ExecutionRequest, + scope: WorkingDirectoryScope, +) -> Result<(), String> { + // Validate the exact string the backends receive: only a genuinely empty + // value means "omitted", and " /tmp" is relative everywhere. + let cwd = request.working_directory.as_str(); + if cwd.is_empty() || !schema_requires_absolute_cwd(&request.schema_version) { + return Ok(()); + } + + let style = request.containment.working_directory_style(scope); + if style.is_absolute(cwd) { + return Ok(()); + } + + Err(format!( + "process.cwd must be an absolute path (e.g. {}), got '{}'. Schema 0.9.0-alpha and \ + later reject a relative working directory because it resolves against the host \ + process's working directory.", + style.example(), + crate::config_deserialize::escape_diagnostic_text(cwd) + )) +} + +/// A malformed version is left to the schema-version validator, which owns that +/// diagnostic. +fn schema_requires_absolute_cwd(version: &str) -> bool { + semver::Version::parse(version) + .is_ok_and(|parsed| (parsed.major, parsed.minor) >= ABSOLUTE_CWD_MIN_SCHEMA) +} + /// Validates non-backend-specific parts of the request (e.g. non-empty script). pub fn validate_common(request: &ExecutionRequest) -> Result<(), ScriptResponse> { if request.script_code.is_empty() { return Err(ScriptResponse::error("Script content must not be empty.")); } + // `Rejected` so the SDK surfaces this as `policy_validation`, matching what + // state-aware `exec` returns for the same cwd. Only `error_message` is set: + // the CLI prints `standard_err` and then the error envelope, so populating + // both would report the same rejection twice. + validate_working_directory(request, WorkingDirectoryScope::OneShot).map_err(|message| { + ScriptResponse { + exit_code: -1, + failure_phase: FailurePhase::Rejected, + error_message: message, + ..Default::default() + } + })?; + // Enforce the testing-only-features gate centrally so it applies uniformly // to all backends — every backend runs `validate_common` before executing. // Currently this gates `network.proxy.builtinTestServer` (a deliberately- @@ -216,14 +273,15 @@ pub fn validate_common(request: &ExecutionRequest) -> Result<(), ScriptResponse> } /// Cross-backend invariants for state-aware `exec`. The dispatcher calls this -/// before the backend's own `validate_exec` hook. Only the exec phase has a -/// common-check today (a non-empty `process.commandLine`). +/// before the backend's own `validate_exec` hook. pub fn validate_exec_common(request: &ExecutionRequest) -> Result<(), MxcError> { if request.script_code.is_empty() { return Err(MxcError::malformed_request( "exec phase requires a non-empty process.commandLine", )); } + validate_working_directory(request, WorkingDirectoryScope::Exec) + .map_err(MxcError::policy_validation)?; Ok(()) } @@ -231,8 +289,8 @@ pub fn validate_exec_common(request: &ExecutionRequest) -> Result<(), MxcError> mod tests { use super::*; use crate::models::{ - ExecutionRequest, NetworkAction, NetworkEgressPolicy, NetworkIngressPolicy, NetworkRule, - ProxyAddress, ProxyConfig, + ContainmentBackend, ExecutionRequest, NetworkAction, NetworkEgressPolicy, + NetworkIngressPolicy, NetworkRule, ProxyAddress, ProxyConfig, }; use crate::mxc_error::MxcErrorCode; @@ -350,6 +408,229 @@ mod tests { assert!(validate_common(&req).is_ok()); } + fn request_with_cwd( + version: &str, + containment: ContainmentBackend, + cwd: &str, + ) -> ExecutionRequest { + ExecutionRequest { + script_code: "echo hi".to_string(), + schema_version: version.to_string(), + containment, + working_directory: cwd.to_string(), + ..Default::default() + } + } + + #[test] + fn rejects_relative_cwd_on_schema_0_9_for_every_backend() { + let backends = [ + ContainmentBackend::ProcessContainer, + ContainmentBackend::WindowsSandbox, + ContainmentBackend::IsolationSession, + ContainmentBackend::Lxc, + ContainmentBackend::Bubblewrap, + ContainmentBackend::Seatbelt, + ContainmentBackend::Wslc, + ContainmentBackend::MicroVm, + ContainmentBackend::Hyperlight, + ContainmentBackend::Vm, + ]; + for backend in backends { + for cwd in [ + "sub", + ".", + "..\\sibling", + "./sub", + "C:relative", + "~", + "~/sub", + ] { + let req = request_with_cwd("0.9.0-alpha", backend.clone(), cwd); + let resp = validate_common(&req) + .expect_err(&format!("{} accepted '{cwd}'", backend.wire_name())); + assert!( + resp.error_message + .contains("process.cwd must be an absolute path"), + "unexpected message: {}", + resp.error_message + ); + // Caller-fixable, so the SDK reports `policy_validation`. + assert_eq!(resp.failure_phase, FailurePhase::Rejected); + } + } + } + + #[test] + fn a_rejected_cwd_is_escaped_before_it_reaches_the_diagnostic() { + let req = request_with_cwd( + "0.9.0-alpha", + ContainmentBackend::Bubblewrap, + "sub\nerror: forged\u{202e}", + ); + let message = validate_common(&req).unwrap_err().error_message; + assert!(!message.contains('\n'), "raw newline in: {message}"); + assert!(message.contains("\\n") && message.contains("\\u{202e}")); + } + + /// Assert `backend` accepts exactly the expected absolute shape on `scope`, + /// and rejects the other one — an absolute path in the wrong style is + /// relative on the target, so both directions must be checked. + #[cfg(target_os = "windows")] + fn assert_cwd_shape(backend: &ContainmentBackend, scope: WorkingDirectoryScope, windows: bool) { + let (accepted, rejected) = if windows { + ("C:\\workspace", "/workspace") + } else { + ("/workspace", "C:\\workspace") + }; + let validate = |cwd: &str| { + let req = request_with_cwd("0.9.0-alpha", backend.clone(), cwd); + match scope { + WorkingDirectoryScope::OneShot => { + validate_common(&req).map_err(|e| e.error_message) + } + WorkingDirectoryScope::Exec => validate_exec_common(&req).map_err(|e| e.message), + } + }; + + let name = backend.wire_name(); + assert!( + validate(accepted).is_ok(), + "{name} rejected '{accepted}' on {scope:?}" + ); + assert!( + validate(rejected).is_err(), + "{name} accepted '{rejected}' on {scope:?}" + ); + } + + /// Pins every entry of `ContainmentBackend::working_directory_style`. The + /// `match` is exhaustive, so a new backend fails to compile until it is + /// covered here rather than silently inheriting someone else's shape. + /// + /// Gated on Windows: Linux and macOS narrow every backend to their own (see + /// `ContainmentBackend::effective_on_host`), which + /// `a_foreign_backend_is_validated_against_the_one_the_host_runs` covers. + #[cfg(target_os = "windows")] + #[test] + fn every_backend_accepts_only_its_own_absolute_cwd_shape() { + for backend in [ + ContainmentBackend::ProcessContainer, + ContainmentBackend::WindowsSandbox, + ContainmentBackend::IsolationSession, + ContainmentBackend::Lxc, + ContainmentBackend::Bubblewrap, + ContainmentBackend::Seatbelt, + ContainmentBackend::Wslc, + ContainmentBackend::MicroVm, + ContainmentBackend::Hyperlight, + ContainmentBackend::Vm, + ] { + let (one_shot_windows, exec_windows) = match &backend { + ContainmentBackend::ProcessContainer + | ContainmentBackend::WindowsSandbox + | ContainmentBackend::IsolationSession => (true, true), + + ContainmentBackend::Lxc + | ContainmentBackend::Bubblewrap + | ContainmentBackend::Seatbelt + | ContainmentBackend::MicroVm + | ContainmentBackend::Hyperlight + | ContainmentBackend::Vm => (false, false), + + // One-shot takes the Windows host path and translates it into + // the container; exec takes the in-container path. + ContainmentBackend::Wslc => (true, false), + }; + + assert_cwd_shape(&backend, WorkingDirectoryScope::OneShot, one_shot_windows); + assert_cwd_shape(&backend, WorkingDirectoryScope::Exec, exec_windows); + } + } + + /// The Windows spellings that are absolute beyond the plain `C:\dir` the + /// matrix above uses, and the drive-relative one that looks absolute. + #[cfg(target_os = "windows")] + #[test] + fn windows_absoluteness_covers_forward_slashes_and_unc_but_not_drive_relative() { + for cwd in ["C:/workspace", "\\\\server\\share", "\\\\?\\C:\\workspace"] { + let req = request_with_cwd("0.9.0-alpha", ContainmentBackend::ProcessContainer, cwd); + assert!(validate_common(&req).is_ok(), "rejected '{cwd}'"); + } + + // `\workspace` is relative to the launcher's current drive. + let drive_relative = request_with_cwd( + "0.9.0-alpha", + ContainmentBackend::ProcessContainer, + "\\workspace", + ); + assert!(validate_common(&drive_relative).is_err()); + } + + /// The engine falls an unsupported request back to LXC on Linux and + /// overrides everything to Seatbelt on macOS without rewriting + /// `containment`, so a Windows-shaped cwd would otherwise reach a POSIX + /// backend as a relative path. + #[cfg(not(target_os = "windows"))] + #[test] + fn a_foreign_backend_is_validated_against_the_one_the_host_runs() { + for backend in [ + ContainmentBackend::ProcessContainer, + ContainmentBackend::WindowsSandbox, + ContainmentBackend::Wslc, + ] { + let req = request_with_cwd("0.9.0-alpha", backend.clone(), "C:\\workspace"); + assert!( + validate_common(&req).is_err(), + "{} accepted a Windows cwd", + backend.wire_name() + ); + + let posix = request_with_cwd("0.9.0-alpha", backend.clone(), "/workspace"); + assert!( + validate_common(&posix).is_ok(), + "{} rejected '/workspace'", + backend.wire_name() + ); + } + } + + #[test] + fn relative_cwd_is_accepted_below_schema_0_9() { + for version in ["", "0.6.0-alpha", "0.8.0-alpha"] { + let req = request_with_cwd(version, ContainmentBackend::ProcessContainer, "sub"); + assert!( + validate_common(&req).is_ok(), + "version '{version}' rejected" + ); + } + } + + #[test] + fn relative_cwd_is_rejected_above_schema_0_9() { + for version in ["0.9.0-dev", "0.10.0", "1.0.0"] { + let req = request_with_cwd(version, ContainmentBackend::ProcessContainer, "sub"); + assert!( + validate_common(&req).is_err(), + "version '{version}' accepted" + ); + } + } + + #[test] + fn an_omitted_cwd_is_still_accepted_on_schema_0_9() { + let req = request_with_cwd("0.9.0-alpha", ContainmentBackend::ProcessContainer, ""); + assert!(validate_common(&req).is_ok()); + } + + #[test] + fn state_aware_exec_rejects_a_relative_cwd_as_policy_validation() { + let req = request_with_cwd("0.9.0-alpha", ContainmentBackend::IsolationSession, "sub"); + let error = validate_exec_common(&req).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::PolicyValidation); + assert!(error.message.contains("process.cwd"), "got {error:?}"); + } + #[test] fn network_support_rejects_unimplemented_features() { let mut request = ExecutionRequest::default(); diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 917a4b17b..a1a0b3f39 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -168,6 +168,14 @@ pub struct Process { /// the system drive root; Seatbelt applies the same precedence with a `/` /// fallback; LXC/WSL use the container root; NanVix and Hyperlight reject a /// working directory outright. See `docs/schema.md` ("Working Directory"). + /// + /// From schema 0.9 on, a supplied value must be absolute for the target the + /// path reaches — `C:\workspace` or a UNC path for the Windows backends, + /// `/workspace` for the Unix ones. WSL Container reads it as a Windows host + /// path one-shot and as an in-container path on a state-aware `exec`. A + /// relative path — including a `~` path, which MXC would have to expand from + /// the launching host's environment — is rejected because it would resolve + /// against the launching process's working directory. pub cwd: Option, /// Environment variables as `"KEY=VALUE"` strings. /// diff --git a/src/testing/wxc_e2e_tests/tests/e2e_working_directory.rs b/src/testing/wxc_e2e_tests/tests/e2e_working_directory.rs new file mode 100644 index 000000000..d6d8563e7 --- /dev/null +++ b/src/testing/wxc_e2e_tests/tests/e2e_working_directory.rs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CLI classification of a refused `process.cwd`. +//! +//! A working directory MXC cannot honour is caller-fixable, so the executor's +//! JSON envelope must carry `policy_validation` — the code the native +//! streaming and state-aware paths already return for the same refusal — rather +//! than the infrastructure-failure `backend_error`. +//! +//! Every refusal asserted here happens in validation, before the backend is +//! asked to launch anything, so these tests need only the platform executor +//! binary: no host prep, no elevation, and no container is ever created. + +use std::sync::OnceLock; + +use serde_json::{json, Value}; +use wxc_e2e_tests::{ + has_platform_exec, run_platform_config_value, run_wxc_config_value, CommandResult, +}; + +static HAS_PLATFORM_EXEC: OnceLock = OnceLock::new(); + +fn cached_has_platform_exec() -> bool { + *HAS_PLATFORM_EXEC.get_or_init(has_platform_exec) +} + +/// The marker the one-shot path emits when the binary was built without +/// `--features wslc`. +const WSLC_NOT_COMPILED: &str = "WSLC backend not compiled"; + +/// Find the single JSON error envelope the executor writes and return its +/// `error` object. +/// +/// Scans line by line the way the Node SDK does: under a PTY the envelope is +/// interleaved with the run's other output, so it is located rather than +/// assumed to be the whole stream. +fn error_envelope(result: &CommandResult) -> Value { + for line in result.combined_output().lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('{') { + continue; + } + if let Ok(Value::Object(parsed)) = serde_json::from_str::(trimmed) { + if let Some(error) = parsed.get("error") { + return error.clone(); + } + } + } + panic!( + "{} emitted no error envelope\n--- stdout ---\n{}\n--- stderr ---\n{}", + result.label, result.stdout, result.stderr, + ); +} + +/// Assert the envelope classifies the refusal as caller-fixable and mentions +/// `expected_text`. +fn assert_policy_validation(result: &CommandResult, expected_text: &str) { + let error = error_envelope(result); + assert_eq!( + error.get("code").and_then(Value::as_str), + Some("policy_validation"), + "a refused working directory must not be reported as an infrastructure \ + failure; envelope: {error}", + ); + let message = error.get("message").and_then(Value::as_str).unwrap_or(""); + assert!( + message.contains(expected_text), + "expected a message mentioning {expected_text:?}, got: {message}", + ); + assert_ne!(result.code, Some(0), "non-zero exit expected on a refusal"); +} + +#[test] +fn one_shot_relative_cwd_is_reported_as_policy_validation() { + if !cached_has_platform_exec() { + return; + } + + // `process` selects the host's native backend, so this runs the same + // refusal on every platform. Schema 0.9.0-alpha is where a relative value + // became invalid. + let config = json!({ + "version": "0.9.0-alpha", + "containment": "process", + "containerId": "cwd-relative", + "process": { "commandLine": "echo unreachable", "cwd": "relative-subdir" }, + }); + let result = run_platform_config_value("one-shot relative cwd", &config, &[], None); + + assert_policy_validation(&result, "process.cwd must be an absolute path"); +} + +#[test] +fn one_shot_wslc_untranslatable_cwd_is_reported_as_policy_validation() { + if !cached_has_platform_exec() || !cfg!(target_os = "windows") { + return; + } + + // A UNC path is absolute on Windows, so it clears the shared schema-0.9 + // check and is refused by WSLc itself: the backend reads `process.cwd` as a + // host path it maps into the container, and a UNC path has no such + // equivalent. That refusal is not version-gated, so it must carry the same + // classification as the schema-driven one above. + let config = json!({ + "version": "0.9.0-alpha", + "containment": "wslc", + "containerId": "cwd-wslc-unc", + "process": { "commandLine": "echo unreachable", "cwd": "\\\\server\\share" }, + "experimental": { "wslc": { "image": "alpine:latest" } }, + }); + let result = run_wxc_config_value("wslc untranslatable cwd", &config, &["--experimental"]); + + if result.combined_output().contains(WSLC_NOT_COMPILED) { + println!("SKIPPED: wxc-exec.exe was built without --features wslc"); + return; + } + assert_policy_validation(&result, "maps into the container"); +}