diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index 0db29ffe9..4bd0e5625 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -219,6 +219,19 @@ The pointer echoes the file's `summary`; the authoritative record is the file itself. In-process Rust callers receive the same information through `Output::output_metadata` or `Sandbox::output_metadata()` after waiting. The C# SDK exposes it through `RunResult.OutputMetadata` and -`MxcSandboxProcess.OutputMetadata`. The intermediate ETW `.etl` trace is an -internal, runner-managed temp file that MXC decodes and then deletes — callers -never see it. +`MxcSandboxProcess.OutputMetadata`. + +By default, the intermediate ETW `.etl` trace is an internal, runner-managed +temp file that MXC deletes after analysis. Set `captureDenials.retainEtl` to +`true` to preserve the sealed trace for diagnostics. When retention succeeds, +the structured pointer and in-process metadata include its absolute +`etlPath`: + +```json +{"type":"captureDenials","outputPath":"C:\\logs\\denials.4321_0123456789abcdef0123456789abcdef.json","exitCode":0,"totalDenials":2,"deniedResourcesTruncated":false,"etlPath":"C:\\Users\\runneradmin\\AppData\\Local\\Temp\\mxc_capture_denials_4321_0123456789abcdef0123456789abcdef.etl"} +``` + +If analysis fails while retention is enabled, MXC preserves the ETL and +includes its path in the returned error. ETL traces can contain sensitive +resource paths and identifiers; callers that retain them are responsible for +restricting access and deleting them when they are no longer needed. diff --git a/docs/schema.md b/docs/schema.md index 63eaccfe4..9460644ee 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -68,10 +68,12 @@ production configs and the dev schema when working on experimental features: // is logged (deny-by-default preserved). "allow": // access is allowed and logged (audit; relaxes // deny-by-default, emits a security warning). - "outputPath": "C:\\logs\\denials.json" // JSON denials file the app reads. The parent - } // dir must already exist; a unique per-run id is stamped - // into the stem (denials..json) and the actual - // path printed on stderr. Omit outputPath for a managed temp file. + "outputPath": "C:\\logs\\denials.json", // JSON denials file the app reads. Parent dir + // must exist; a unique per-run id is stamped into the + // stem and the actual path is printed on stderr. + "retainEtl": false // Keep the sealed ETL after analysis and report its + // path in output metadata. Defaults to false. + } // Omit outputPath for a managed temp file. // captureDenials cannot be combined with leastPrivilege. // captureDenials cannot currently be combined with network.proxy. }, diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index 504446da6..7dc708e1f 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -60,11 +60,18 @@ "description": "How each ungranted access check is handled while it is recorded. Both modes log every access the policy does not grant to the ETL trace; the mode only decides whether that access is blocked or allowed. Defaults to `block` when omitted." }, "outputPath": { - "description": "Absolute path where the JSON denials output file is written — the deliverable a consuming application reads to learn what the workload was denied. It is a single JSON document `{ \"denials\": [...], \"summary\": {...} }`. A per-run identifier (process id plus random suffix) is inserted into the file stem (e.g. `denials.json` -> `denials..json`) so concurrent and sequential captures do not collide; the actual path is reported on stderr. When omitted, MXC writes it to a managed per-run temporary file and prints its path on stderr. The parent directory must already exist. (The intermediate ETL trace is an internal, runner-managed temp file that is decoded then deleted.)", + "description": "Absolute path where the JSON denials output file is written — the deliverable a consuming application reads to learn what the workload was denied. It is a single JSON document `{ \"denials\": [...], \"summary\": {...} }`. A per-run identifier (process id plus random suffix) is inserted into the file stem (e.g. `denials.json` -> `denials..json`) so concurrent and sequential captures do not collide; the actual path is reported on stderr. When omitted, MXC writes it to a managed per-run temporary file and prints its path on stderr. The parent directory must already exist. (The intermediate ETL trace is an internal, runner-managed temp file.)", "type": [ "string", "null" ] + }, + "retainEtl": { + "description": "Keep the sealed ETL trace after analysis and report its path in output metadata. Defaults to `false`, which deletes the trace after analysis. Retained traces can contain sensitive resource paths and identifiers; callers are responsible for securing and deleting them.", + "type": [ + "boolean", + "null" + ] } }, "type": "object" diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 100da3cc6..ddda618e8 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -66,4 +66,24 @@ public void SandboxPolicy_SerializesToCamelCaseJson() Assert.Equal("read", root.GetProperty("ui").GetProperty("clipboard").GetString()); Assert.True(root.GetProperty("ui").GetProperty("allowWindows").GetBoolean()); } + + [Fact] + public void CaptureDenialsOutput_DeserializesRetainedEtlPath() + { + const string json = """ + { + "type": "captureDenials", + "outputPath": "denials.json", + "exitCode": 0, + "totalDenials": 1, + "deniedResourcesTruncated": false, + "etlPath": "capture.etl" + } + """; + + var output = JsonSerializer.Deserialize(json); + + Assert.NotNull(output); + Assert.Equal("capture.etl", output.EtlPath); + } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxOutputMetadata.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxOutputMetadata.cs index aa9d3d9e9..5a2ca46b5 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxOutputMetadata.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxOutputMetadata.cs @@ -35,4 +35,8 @@ public sealed class CaptureDenialsOutput /// Whether the emitted denial set was truncated. [JsonPropertyName("deniedResourcesTruncated")] public bool DeniedResourcesTruncated { get; init; } + + /// Absolute path to the retained ETL trace, when requested. + [JsonPropertyName("etlPath")] + public string? EtlPath { get; init; } } diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index e7c8ae11c..605c00313 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -40,7 +40,8 @@ catch (MxcException ex) thread pool. `MxcSandbox.NativeVersion` returns the loaded `mxc_ffi` version. Optional feature outputs are returned through `RunResult.OutputMetadata`; for `captureDenials`, `OutputMetadata.CaptureDenials.OutputPath` identifies the -generated JSON document and carries its summary. +generated JSON document and carries its summary. When ETL retention is enabled, +`OutputMetadata.CaptureDenials.EtlPath` identifies the retained trace. ### Streaming diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 4d18d61ea..30510ef75 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -46,9 +46,13 @@ export interface CaptureDenials { */ mode?: CaptureDenialsMode | null; /** - * Absolute path where the JSON denials output file is written — the deliverable a consuming application reads to learn what the workload was denied. It is a single JSON document `{ "denials": [...], "summary": {...} }`. A per-run identifier (process id plus random suffix) is inserted into the file stem (e.g. `denials.json` -> `denials..json`) so concurrent and sequential captures do not collide; the actual path is reported on stderr. When omitted, MXC writes it to a managed per-run temporary file and prints its path on stderr. The parent directory must already exist. (The intermediate ETL trace is an internal, runner-managed temp file that is decoded then deleted.) + * Absolute path where the JSON denials output file is written — the deliverable a consuming application reads to learn what the workload was denied. It is a single JSON document `{ "denials": [...], "summary": {...} }`. A per-run identifier (process id plus random suffix) is inserted into the file stem (e.g. `denials.json` -> `denials..json`) so concurrent and sequential captures do not collide; the actual path is reported on stderr. When omitted, MXC writes it to a managed per-run temporary file and prints its path on stderr. The parent directory must already exist. (The intermediate ETL trace is an internal, runner-managed temp file.) */ outputPath?: string | null; + /** + * Keep the sealed ETL trace after analysis and report its path in output metadata. Defaults to `false`, which deletes the trace after analysis. Retained traces can contain sensitive resource paths and identifiers; callers are responsible for securing and deleting them. + */ + retainEtl?: boolean | null; } /** diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index b89abedb9..3c4d62b56 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -1266,9 +1266,9 @@ impl BaseContainerRunner { } // Resolve two paths for the capture: - // * `capture_etl_path` — an always-internal, runner-managed temp `.etl` - // that the OS broker seals into. It is decoded then deleted in - // `run_teardown`; callers never see it. + // * `capture_etl_path` — a runner-managed temp `.etl` that the OS + // broker seals into. It is decoded in `run_teardown`, then deleted + // unless `captureDenials.retainEtl` was requested. // * `capture_output_path` — the JSON denials deliverable that consuming // apps read: caller-specified via `captureDenials.outputPath` when // provided, else a managed per-run temp `.json` file. @@ -1980,6 +1980,9 @@ impl BaseContainerRunner { security_environment, capture_etl_path, capture_output_path, + retain_capture_etl: capture_denials + .as_ref() + .is_some_and(|config| config.retain_etl), }) } } @@ -2013,12 +2016,14 @@ struct BaseChild { /// Non-capture PSEC environment for schema 0.8+ requests. Retained until /// the child exits so policy enforcement outlives the process tree. security_environment: Option, - /// Internal runner-managed temp `.etl` the broker seals into. Decoded - /// then deleted in `run_teardown`. `Some` iff `capture_session` is `Some`. + /// Internal runner-managed temp `.etl` the broker seals into. Decoded in + /// `run_teardown`. `Some` iff `capture_session` is `Some`. capture_etl_path: Option, /// Resolved JSON denials deliverable path (caller-specified or a managed /// per-run temp file). `Some` iff `capture_session` is `Some`. capture_output_path: Option, + /// Whether the sealed ETL is retained after analysis. + retain_capture_etl: bool, } impl SandboxBackend for BaseContainerRunner { @@ -2184,6 +2189,8 @@ struct BaseContainerSandboxProcess { capture_etl_path: Option, /// Resolved JSON denials deliverable path. capture_output_path: Option, + /// Whether the sealed ETL is retained after analysis. + retain_capture_etl: bool, /// Exit code of the child, recorded by `wait` before teardown so the /// denials summary can carry it. `None` on the `Drop`/early-exit path. last_exit_code: Option, @@ -2227,6 +2234,7 @@ impl BaseContainerSandboxProcess { security_environment: child.security_environment.take(), capture_etl_path: child.capture_etl_path.take(), capture_output_path: child.capture_output_path.take(), + retain_capture_etl: child.retain_capture_etl, last_exit_code: None, output_metadata: None, } @@ -2240,39 +2248,40 @@ impl BaseContainerSandboxProcess { // Seal the learning-mode ETL trace now that the child has exited and // been reaped (both `wait` and `Drop` kill + reap before calling this). - // The ETL is an internal temp: seal it, decode it into the JSON denials - // deliverable that consuming apps read, delete the temp, and retain - // structured metadata for the caller. Any seal/decode/write failure is - // returned through `wait()`. + // Seal the ETL, decode it into the JSON denials deliverable, and either + // delete it or report its retained path according to the request. Any + // seal/decode/write failure is returned through `wait()`. let capture_result = if let Some(session) = self.capture_session.take() { let etl_path = self.capture_etl_path.take(); let output_path = self.capture_output_path.take(); let exit_code = self.last_exit_code.unwrap_or(-1); let result = match session.finish(etl_path.as_deref()) { Ok(()) => match (&etl_path, &output_path) { - (Some(etl), Some(output)) => { - Self::decode_write_and_cleanup(&EtlDenialAnalyzer, etl, output, exit_code) - .map(Some) - } - _ => combine_capture_and_cleanup_results( + (Some(etl), Some(output)) => Self::decode_write_and_finalize( + &EtlDenialAnalyzer, + etl, + output, + exit_code, + self.retain_capture_etl, + ) + .map(Some), + _ => finalize_capture_result( Err(std::io::Error::other( "captureDenials internal output paths were not initialized", )), - etl_path - .as_deref() - .map(remove_internal_capture_file) - .unwrap_or(Ok(())), - ), + etl_path.as_deref(), + self.retain_capture_etl, + ) + .map(Some), }, - Err(error) => combine_capture_and_cleanup_results( + Err(error) => finalize_capture_result( Err(std::io::Error::other(format!( "captureDenials failed to finalize the denial capture: {error}" ))), - etl_path - .as_deref() - .map(remove_internal_capture_file) - .unwrap_or(Ok(())), - ), + etl_path.as_deref(), + self.retain_capture_etl, + ) + .map(Some), }; if let Ok(Some(metadata)) = &result { self.output_metadata = Some(SandboxOutputMetadata { @@ -2349,22 +2358,58 @@ impl BaseContainerSandboxProcess { exit_code: pointer.exit_code, total_denials: pointer.total_denials, denied_resources_truncated: pointer.denied_resources_truncated, + etl_path: None, }) } - fn decode_write_and_cleanup( + fn decode_write_and_finalize( analyzer: &dyn DenialAnalyzer, etl_path: &Path, output_path: &Path, exit_code: i32, + retain_etl: bool, ) -> std::io::Result { - combine_capture_and_cleanup_results( + finalize_capture_result( Self::decode_and_write_denials(analyzer, etl_path, output_path, exit_code), - remove_internal_capture_file(etl_path), + Some(etl_path), + retain_etl, ) } } +fn finalize_capture_result( + capture_result: std::io::Result, + etl_path: Option<&Path>, + retain_etl: bool, +) -> std::io::Result { + if retain_etl { + let Some(etl_path) = etl_path else { + return capture_result; + }; + let retained_path = etl_path.to_string_lossy().into_owned(); + return capture_result + .map(|mut output| { + output.etl_path = Some(retained_path); + output + }) + .map_err(|error| { + if etl_path.exists() { + std::io::Error::other(format!( + "{error}; retained ETL file at {}", + etl_path.display() + )) + } else { + error + } + }); + } + + combine_capture_and_cleanup_results( + capture_result, + etl_path.map(remove_internal_capture_file).unwrap_or(Ok(())), + ) +} + fn remove_internal_capture_file(path: &Path) -> std::io::Result<()> { match std::fs::remove_file(path) { Ok(()) => Ok(()), @@ -2612,12 +2657,10 @@ fn combine_process_and_teardown_results( (Ok(exit_code), Ok(())) => Ok(exit_code), (Ok(_), Err(teardown_error)) => Err(teardown_error), (Err(wait_error), Ok(())) => Err(wait_error), - (Err(wait_error), Err(teardown_error)) => { - write_stderr_line_best_effort(format_args!( - "captureDenials teardown also failed after process wait failure: {teardown_error}" - )); - Err(wait_error) - } + (Err(wait_error), Err(teardown_error)) => Err(std::io::Error::new( + wait_error.kind(), + format!("{wait_error}; captureDenials teardown also failed: {teardown_error}"), + )), } } @@ -2902,11 +2945,12 @@ mod tests { result: Err("simulated decode failure"), }; - let error = BaseContainerSandboxProcess::decode_write_and_cleanup( + let error = BaseContainerSandboxProcess::decode_write_and_finalize( &analyzer, &etl_path, &output_path, 0, + false, ) .expect_err("decode should fail"); @@ -2915,6 +2959,84 @@ mod tests { assert!(!output_path.exists()); } + #[test] + fn default_etl_cleanup_removes_file_after_success() { + let directory = tempfile::tempdir().expect("temp directory"); + let etl_path = directory.path().join("capture.etl"); + let output_path = directory.path().join("denials.json"); + std::fs::write(&etl_path, b"fake etl").expect("seed ETL"); + let analyzer = FakeAnalyzer { + result: Ok(AnalysisResult::complete(Vec::new())), + }; + + let metadata = BaseContainerSandboxProcess::decode_write_and_finalize( + &analyzer, + &etl_path, + &output_path, + 0, + false, + ) + .expect("decode should succeed"); + + assert!(metadata.etl_path.is_none()); + assert!(!etl_path.exists()); + assert!(output_path.exists()); + } + + #[test] + fn requested_etl_retention_reports_path_and_preserves_file() { + let directory = tempfile::tempdir().expect("temp directory"); + let etl_path = directory.path().join("capture.etl"); + let output_path = directory.path().join("denials.json"); + std::fs::write(&etl_path, b"fake etl").expect("seed ETL"); + let analyzer = FakeAnalyzer { + result: Ok(AnalysisResult::complete(Vec::new())), + }; + + let metadata = BaseContainerSandboxProcess::decode_write_and_finalize( + &analyzer, + &etl_path, + &output_path, + 0, + true, + ) + .expect("decode should succeed"); + + assert_eq!( + metadata.etl_path.as_deref(), + Some(etl_path.to_string_lossy().as_ref()) + ); + assert!(etl_path.exists()); + assert!(output_path.exists()); + } + + #[test] + fn requested_etl_retention_preserves_file_when_analysis_fails() { + let directory = tempfile::tempdir().expect("temp directory"); + let etl_path = directory.path().join("capture.etl"); + let output_path = directory.path().join("denials.json"); + std::fs::write(&etl_path, b"fake etl").expect("seed ETL"); + let analyzer = FakeAnalyzer { + result: Err("simulated decode failure"), + }; + + let error = BaseContainerSandboxProcess::decode_write_and_finalize( + &analyzer, + &etl_path, + &output_path, + 0, + true, + ) + .expect_err("decode should fail"); + + let message = error.to_string(); + assert!(message.contains("simulated decode failure")); + assert!(message.contains("retained ETL file at")); + assert!(message.contains(&etl_path.to_string_lossy().into_owned())); + assert!(etl_path.exists()); + assert!(!output_path.exists()); + } + #[test] fn successful_process_reports_capture_teardown_failure() { let error = @@ -2924,6 +3046,26 @@ mod tests { assert!(error.to_string().contains("seal failed")); } + #[test] + fn wait_and_capture_failures_preserve_retained_etl_path() { + let error = combine_process_and_teardown_results( + Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "script timed out after 1000ms", + )), + Err(std::io::Error::other( + r"decode failed; retained ETL file at C:\Temp\capture.etl", + )), + ) + .expect_err("both failures should be reported"); + + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + let message = error.to_string(); + assert!(message.contains("script timed out after 1000ms")); + assert!(message.contains("decode failed")); + assert!(message.contains(r"C:\Temp\capture.etl")); + } + #[test] fn insert_run_id_into_stem_injects_id_before_extension() { let got = insert_run_id_into_stem(Path::new(r"C:\app\denials.json"), "1234_abcd"); diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 9db711921..0f3c2fd79 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -79,14 +79,17 @@ let policy = SandboxPolicy { // Absolute path; a per-run id is stamped into the stem // (`denials.json` -> `denials..json`). `None` uses a managed temp. output_path: None, + // Preserve the sealed ETL and report its path in output metadata. + retain_etl: false, }), }; ``` `Allow` relaxes containment for the run — it is reported through `warnings()`. Read the resulting file path and denial summary from `output_metadata()` after -the process terminates. The section is ignored on Linux and macOS, whose -backends have no learning-mode API. +the process terminates. When `retain_etl` is enabled, the capture output's +`etl_path` identifies the retained trace. The section is ignored on Linux and +macOS, whose backends have no learning-mode API. ## Live stdio + kill (streaming) @@ -134,7 +137,8 @@ The handle is modelled on [`std::process::Child`]: - `warnings()` returns policy security warnings detected while spawning the sandbox, such as `permissiveLearningMode` weakening deny-by-default. - `output_metadata()` returns structured feature outputs after a terminal wait. - For `captureDenials`, it contains the generated JSON file path and summary. + For `captureDenials`, it contains the generated JSON file path and summary, + plus the retained ETL path when requested. - `kill()` terminates the sandboxed process **and its descendants** (a process-tree kill): on Unix the child leads its own process group and the whole group is signalled (an immediate `SIGKILL`, no graceful `SIGTERM`); diff --git a/src/core/mxc-sdk/src/sandbox.rs b/src/core/mxc-sdk/src/sandbox.rs index fc3b57a7a..0d5858d7d 100644 --- a/src/core/mxc-sdk/src/sandbox.rs +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -237,6 +237,7 @@ mod tests { exit_code: 0, total_denials: 2, denied_resources_truncated: false, + etl_path: None, }), }), })); diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index a2703c67b..b68adf384 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -548,6 +548,9 @@ pub struct CaptureDenialsSection { /// a managed per-run temporary file is used. The parent directory must /// already exist. pub output_path: Option, + /// Preserve the sealed ETL trace after analysis and report its path in + /// output metadata. Defaults to `false`. + pub retain_etl: bool, } /// The containment backend [`build_request_with_containment`] targets — the @@ -988,6 +991,7 @@ fn apply_host_process_backend( config["processContainer"]["captureDenials"] = json!({ "mode": cd.mode.wire(), "outputPath": cd.output_path, + "retainEtl": cd.retain_etl, }); } if let Some(network) = config.get_mut("network") { @@ -1360,11 +1364,13 @@ mod tests { let section: CaptureDenialsSection = serde_json::from_value(serde_json::json!({ "mode": "allow", "outputPath": "/tmp/denials.json", + "retainEtl": true, })) .expect("section deserializes"); assert_eq!(section.mode, CaptureDenialsMode::Allow); assert_eq!(section.output_path.as_deref(), Some("/tmp/denials.json")); + assert!(section.retain_etl); } #[test] @@ -1374,6 +1380,7 @@ mod tests { assert_eq!(section.mode, CaptureDenialsMode::Block); assert!(section.output_path.is_none()); + assert!(!section.retain_etl); } #[cfg(target_os = "windows")] @@ -1388,6 +1395,7 @@ mod tests { let policy = policy_with_capture_denials(CaptureDenialsSection { mode: CaptureDenialsMode::Allow, output_path: Some(expected.clone()), + retain_etl: true, }); let request = build_request(&policy, None).expect("build_request"); @@ -1399,6 +1407,7 @@ mod tests { .expect("captureDenials enabled"); assert_eq!(captured.mode, DomainMode::Allow); assert_eq!(captured.output_path.as_deref(), Some(expected.as_str())); + assert!(captured.retain_etl); } #[cfg(target_os = "windows")] @@ -1422,6 +1431,7 @@ mod tests { let policy = policy_with_capture_denials(CaptureDenialsSection { mode: CaptureDenialsMode::Allow, output_path: Some("/tmp/denials.json".to_string()), + retain_etl: true, }); let request = build_request(&policy, None).expect("build_request"); assert!(request.inner.policy.capture_denials.is_none()); @@ -1440,21 +1450,25 @@ mod tests { let emitted = serde_json::json!({ "mode": mode.wire(), "outputPath": Some("/tmp/denials.json"), + "retainEtl": true, }); let parsed: wire::CaptureDenials = serde_json::from_value(emitted).expect("emitted object satisfies the wire type"); assert_eq!(parsed.mode, Some(expected)); assert_eq!(parsed.output_path.as_deref(), Some("/tmp/denials.json")); + assert_eq!(parsed.retain_etl, Some(true)); } let omitted = serde_json::json!({ "mode": CaptureDenialsMode::Block.wire(), "outputPath": Option::::None, + "retainEtl": Option::::None, }); let parsed: wire::CaptureDenials = serde_json::from_value(omitted).expect("null outputPath satisfies the wire type"); assert!(parsed.output_path.is_none()); + assert!(parsed.retain_etl.is_none()); } // `captureDenials` and `network.proxy` are independent: capture records @@ -1510,6 +1524,7 @@ mod tests { policy.capture_denials = Some(CaptureDenialsSection { mode: CaptureDenialsMode::Allow, output_path: Some(expected.clone()), + retain_etl: true, }); let request = build_request(&policy, None) @@ -1522,6 +1537,7 @@ mod tests { .as_ref() .expect("captureDenials enabled"); assert_eq!(captured.output_path.as_deref(), Some(expected.as_str())); + assert!(captured.retain_etl); assert!(request.inner.policy.network_proxy.is_enabled()); } diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index ccac609b5..01d6276c8 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -1481,6 +1481,7 @@ mod tests { request.policy.capture_denials = Some(CaptureDenialsConfig { mode, output_path: None, + retain_etl: false, }); let error = validate_audit_request(&request) diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index a51dba6df..8032ed90f 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -909,6 +909,7 @@ fn convert_wire_config( policy.capture_denials = Some(CaptureDenialsConfig { mode, output_path: cd.output_path, + retain_etl: cd.retain_etl.unwrap_or(false), }); } @@ -2485,10 +2486,25 @@ mod tests { .capture_denials .expect("captureDenials presence should enable capture"); assert!(cd.output_path.is_none()); + assert!(!cd.retain_etl); // Omitting `mode` defaults to the safe block behavior. assert_eq!(cd.mode, CaptureDenialsMode::Block); } + #[test] + fn capture_denials_retain_etl_is_parsed() { + let json = r#"{ + "process": {"commandLine": "print('test')"}, + "containment": "processcontainer", + "processContainer": {"captureDenials": {"retainEtl": true}} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let req = load_request(&encoded, &mut logger, true).unwrap(); + let cd = req.policy.capture_denials.expect("captureDenials present"); + assert!(cd.retain_etl); + } + #[test] fn capture_denials_mode_block_is_parsed() { let json = r#"{ diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 1bc33be4f..d2a087062 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -579,9 +579,11 @@ pub struct CaptureDenialsConfig { /// `denials..json`) so concurrent and sequential captures don't /// collide, and reports the actual path on stderr. When `None`, the runner /// falls back to a managed per-run temporary file and prints its path on - /// stderr. (The intermediate ETL trace is an internal runner temp that is - /// decoded then deleted.) + /// stderr. pub output_path: Option, + /// Whether to preserve the sealed ETL trace after analysis. Defaults to + /// `false`, which deletes the internal trace. + pub retain_etl: bool, } /// How `captureDenials` handles each ungranted access check while recording it. @@ -836,6 +838,9 @@ pub struct CaptureDenialsOutput { pub total_denials: usize, /// Whether the emitted denial set was truncated. pub denied_resources_truncated: bool, + /// Absolute path to the retained ETL trace, when retention was requested. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub etl_path: Option, } impl CaptureDenialsOutput { @@ -893,6 +898,36 @@ mod tests { } } + #[test] + fn capture_denials_output_omits_unretained_etl_path() { + let output = CaptureDenialsOutput { + kind: CaptureDenialsOutput::KIND.to_string(), + output_path: "denials.json".to_string(), + exit_code: 0, + total_denials: 1, + denied_resources_truncated: false, + etl_path: None, + }; + + let value = serde_json::to_value(output).unwrap(); + assert!(value.get("etlPath").is_none()); + } + + #[test] + fn capture_denials_output_serializes_retained_etl_path() { + let output = CaptureDenialsOutput { + kind: CaptureDenialsOutput::KIND.to_string(), + output_path: "denials.json".to_string(), + exit_code: 0, + total_denials: 1, + denied_resources_truncated: false, + etl_path: Some("capture.etl".to_string()), + }; + + let value = serde_json::to_value(output).unwrap(); + assert_eq!(value["etlPath"], "capture.etl"); + } + #[test] fn isolation_session_user_serde_round_trips_camel_case() { let wire = json!({"upn": "alice@contoso.com", "wamToken": "tok"}); diff --git a/src/core/wxc_common/src/sandbox_process.rs b/src/core/wxc_common/src/sandbox_process.rs index 058911774..33303da27 100644 --- a/src/core/wxc_common/src/sandbox_process.rs +++ b/src/core/wxc_common/src/sandbox_process.rs @@ -432,13 +432,23 @@ impl ScriptRunner for Runner { } response } - Err(e) if e.kind() == std::io::ErrorKind::TimedOut => ScriptResponse { - exit_code: -1, - error_message: format!("script timed out after {}ms", request.script_timeout), - output_metadata: child.output_metadata().cloned().map(Box::new), - failure_phase: FailurePhase::Timeout, - ..Default::default() - }, + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => { + let timeout_message = + format!("script timed out after {}ms", request.script_timeout); + let detail = e.to_string(); + let error_message = if detail.starts_with(&timeout_message) { + detail + } else { + format!("{timeout_message}; {detail}") + }; + ScriptResponse { + exit_code: -1, + error_message, + output_metadata: child.output_metadata().cloned().map(Box::new), + failure_phase: FailurePhase::Timeout, + ..Default::default() + } + } Err(e) => { let mut response = ScriptResponse::error(&format!("wait failed: {e}")); response.output_metadata = child.output_metadata().cloned().map(Box::new); diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 19e9028c9..600aeefcd 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -240,9 +240,13 @@ pub struct CaptureDenials { /// collide; the actual path is reported on stderr. When omitted, MXC /// writes it to a managed per-run temporary file and prints its path on /// stderr. The parent directory must already exist. (The intermediate ETL - /// trace is an internal, runner-managed temp file that is decoded then - /// deleted.) + /// trace is an internal, runner-managed temp file.) pub output_path: Option, + /// Keep the sealed ETL trace after analysis and report its path in output + /// metadata. Defaults to `false`, which deletes the trace after analysis. + /// Retained traces can contain sensitive resource paths and identifiers; + /// callers are responsible for securing and deleting them. + pub retain_etl: Option, } /// How `captureDenials` handles each ungranted access check while recording it.