From 6434fc35dbd6731b225ded2f9732edaaac42e014 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Sun, 20 Sep 2026 18:44:17 +0500 Subject: [PATCH 01/13] feat!: add structured configuration diagnostics Co-authored-by: codex --- README.md | 1 + .../src/main/kotlin/ai/cageforge/Cageforge.kt | 18 +- .../kotlin/ai/cageforge/CageforgeException.kt | 14 +- .../kotlin/ai/cageforge/RuntimeContextTest.kt | 22 ++ crates/bindings/cageforge-java/src/error.rs | 118 +++++++++- crates/bindings/cageforge-java/src/lib.rs | 23 +- .../python/cageforge/_cageforge.pyi | 8 +- .../cageforge-python/src/bin/stub_gen.rs | 4 + crates/bindings/cageforge-python/src/lib.rs | 90 ++++++-- .../cageforge-python/tests/test_native.py | 35 +++ crates/cageforge-cli/src/error.rs | 11 + crates/cageforge-cli/src/execution.rs | 1 + crates/cageforge-cli/src/lib.rs | 2 +- crates/cageforge-cli/tests/cli.rs | 26 +++ ...error_is_rendered_with_source_context.snap | 9 + crates/cageforge-config/src/build/command.rs | 7 + crates/cageforge-config/src/build/gateway.rs | 2 + crates/cageforge-config/src/build/policy.rs | 17 ++ crates/cageforge-config/src/diagnostics.rs | 51 ++++- crates/cageforge-config/src/error.rs | 207 +++++++++++++++++- crates/cageforge-config/src/lib.rs | 2 +- crates/cageforge-config/src/resolve.rs | 158 +++++++++++-- crates/cageforge-config/tests/config.rs | 33 ++- crates/cageforge-config/tests/platforms.rs | 6 + crates/cageforge-macos/src/backend.rs | 41 +++- crates/cageforge-macos/src/error.rs | 14 ++ crates/cageforge-macos/src/lib.rs | 1 + crates/cageforge-macos/src/runtime.rs | 19 ++ crates/cageforge-macos/src/seatbelt.rs | 22 +- crates/cageforge-macos/src/seatbelt_tests.rs | 32 ++- crates/cageforge-macos/tests/macos_backend.rs | 18 ++ specs/0024-macos-executable-runtime-roots.md | 27 ++- ...26-structured-configuration-diagnostics.md | 56 +++++ 33 files changed, 1004 insertions(+), 91 deletions(-) create mode 100644 crates/cageforge-cli/tests/snapshots/cli__configuration_error_is_rendered_with_source_context.snap create mode 100644 crates/cageforge-macos/src/runtime.rs create mode 100644 specs/0026-structured-configuration-diagnostics.md diff --git a/README.md b/README.md index 35f49b0a..1a5267ef 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,7 @@ The legal and provenance records are maintained in [`specs/0023-local-ipc-capability.md`](specs/0023-local-ipc-capability.md), [`specs/0024-macos-executable-runtime-roots.md`](specs/0024-macos-executable-runtime-roots.md), [`specs/0025-dynamic-permission-escalation.md`](specs/0025-dynamic-permission-escalation.md), +[`specs/0026-structured-configuration-diagnostics.md`](specs/0026-structured-configuration-diagnostics.md), [`NOTICE`](NOTICE), [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md), and [`UPSTREAM.md`](UPSTREAM.md). diff --git a/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/Cageforge.kt b/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/Cageforge.kt index 08b38e9f..f943b368 100644 --- a/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/Cageforge.kt +++ b/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/Cageforge.kt @@ -209,6 +209,22 @@ class Cageforge private constructor( context: RuntimeContext = RuntimeContext(file.toAbsolutePath().parent ?: Path.of(".")), grant: PermissionGrant? = null, request: PermissionRequest? = null, - ): Cageforge = fromToml(Files.readString(file), profileName, context, grant, request) + ): Cageforge { + try { + return fromToml(Files.readString(file), profileName, context, grant, request) + } catch (error: CageforgeConfigurationException) { + if (error.configPath != null) throw error + throw CageforgeConfigurationException( + error.message ?: "configuration error", + error.code, + file.toAbsolutePath().toString(), + error.profile, + error.platform, + error.field, + error.line, + error.column, + ) + } + } } } diff --git a/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/CageforgeException.kt b/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/CageforgeException.kt index 478e9ce3..aa2d67d1 100644 --- a/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/CageforgeException.kt +++ b/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/CageforgeException.kt @@ -14,7 +14,19 @@ open class CageforgeException class UnsupportedPlatformException(message: String) : CageforgeException(message) /** Raised when TOML configuration cannot be parsed or resolved. */ -class CageforgeConfigurationException(message: String) : CageforgeException(message) +class CageforgeConfigurationException( + message: String, + val code: String, + val configPath: String?, + val profile: String?, + val platform: String?, + val field: String?, + val line: Int?, + val column: Int?, +) : CageforgeException(message) { + /** Creates a category-only configuration failure for host-side validation. */ + constructor(message: String) : this(message, "configuration_error", null, null, null, null, null, null) +} /** Raised when the native Cageforge runtime cannot be initialized. */ class CageforgeInitializationException(message: String) : CageforgeException(message) diff --git a/crates/bindings/cageforge-java/jvm/src/test/kotlin/ai/cageforge/RuntimeContextTest.kt b/crates/bindings/cageforge-java/jvm/src/test/kotlin/ai/cageforge/RuntimeContextTest.kt index c04a790d..73ab7c36 100644 --- a/crates/bindings/cageforge-java/jvm/src/test/kotlin/ai/cageforge/RuntimeContextTest.kt +++ b/crates/bindings/cageforge-java/jvm/src/test/kotlin/ai/cageforge/RuntimeContextTest.kt @@ -22,6 +22,28 @@ class RuntimeContextTest { assertFailsWith { RuntimeContext(Path.of("relative")) } } + @Test + fun configurationExceptionKeepsStructuredDiagnosticFields() { + val error = + CageforgeConfigurationException( + "invalid configuration", + "invalid_value", + "/tmp/tool.toml", + "broken", + "macos", + "command.program", + 12, + 3, + ) + assertEquals("invalid_value", error.code) + assertEquals("/tmp/tool.toml", error.configPath) + assertEquals("broken", error.profile) + assertEquals("macos", error.platform) + assertEquals("command.program", error.field) + assertEquals(12, error.line) + assertEquals(3, error.column) + } + @Test fun windowsSetupIsTypedAndNonDestructiveOnNonWindows() { if (WindowsSetup.isSupported()) return diff --git a/crates/bindings/cageforge-java/src/error.rs b/crates/bindings/cageforge-java/src/error.rs index df53bb2c..5ae8840b 100644 --- a/crates/bindings/cageforge-java/src/error.rs +++ b/crates/bindings/cageforge-java/src/error.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 use jni::errors::ErrorPolicy; -use jni::objects::JClass; +use jni::objects::{JClass, JObject, JThrowable, JValue}; use jni::{Env, EnvUnowned}; /// Stable exception categories exposed by the JVM binding. @@ -90,10 +90,42 @@ impl BindingErrorKind { } } +#[derive(Debug)] +pub(crate) struct BindingDiagnostic { + pub(crate) code: String, + pub(crate) config_path: Option, + pub(crate) profile: Option, + pub(crate) platform: Option, + pub(crate) field: Option, + pub(crate) line: Option, + pub(crate) column: Option, +} + +impl BindingDiagnostic { + pub(crate) fn from_config(error: &cageforge::ConfigError) -> Self { + let diagnostic = error.diagnostic(); + let location = diagnostic.location(); + Self { + code: diagnostic.code().to_owned(), + config_path: diagnostic + .config_path() + .map(|path| path.display().to_string()), + profile: diagnostic.profile().map(str::to_owned), + platform: diagnostic + .platform() + .map(|platform| platform.as_str().to_owned()), + field: diagnostic.field().map(str::to_owned), + line: location.and_then(|value| i32::try_from(value.line).ok()), + column: location.and_then(|value| i32::try_from(value.column).ok()), + } + } +} + #[derive(Debug)] pub(crate) struct BindingError { pub(crate) kind: BindingErrorKind, message: String, + diagnostic: Option>, } impl BindingError { @@ -101,6 +133,16 @@ impl BindingError { Self { kind, message: message.into(), + diagnostic: None, + } + } + + pub(crate) fn configuration(error: cageforge::ConfigError) -> Self { + let diagnostic = error.diagnostic(); + Self { + kind: BindingErrorKind::Configuration, + message: diagnostic.render_human(), + diagnostic: Some(Box::new(BindingDiagnostic::from_config(&error))), } } @@ -125,6 +167,7 @@ impl From for BindingError { Self { kind: BindingErrorKind::Internal, message, + diagnostic: None, } } } @@ -141,6 +184,68 @@ impl From for BindingError { } } +fn throw_configuration_exception( + env: &mut Env<'_>, + class: &JClass<'_>, + message: &str, + diagnostic: &BindingDiagnostic, +) -> jni::errors::Result<()> { + let message = env.new_string(message)?; + let code = env.new_string(&diagnostic.code)?; + let config_path = diagnostic + .config_path + .as_deref() + .map(|value| env.new_string(value)) + .transpose()?; + let profile = diagnostic + .profile + .as_deref() + .map(|value| env.new_string(value)) + .transpose()?; + let platform = diagnostic + .platform + .as_deref() + .map(|value| env.new_string(value)) + .transpose()?; + let field = diagnostic + .field + .as_deref() + .map(|value| env.new_string(value)) + .transpose()?; + let line = match diagnostic.line { + Some(value) => env.new_object( + jni::jni_str!("java/lang/Integer"), + jni::jni_sig!("(I)V"), + &[JValue::Int(value)], + )?, + None => JObject::null(), + }; + let column = match diagnostic.column { + Some(value) => env.new_object( + jni::jni_str!("java/lang/Integer"), + jni::jni_sig!("(I)V"), + &[JValue::Int(value)], + )?, + None => JObject::null(), + }; + let object = env.new_object( + class, + jni::jni_sig!("(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V"), + &[ + JValue::Object(message.as_ref()), + JValue::Object(code.as_ref()), + JValue::Object(config_path.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())), + JValue::Object(profile.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())), + JValue::Object(platform.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())), + JValue::Object(field.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())), + JValue::Object(&line), + JValue::Object(&column), + ], + )?; + let throwable = unsafe { JThrowable::from_raw(env, object.into_raw()) }; + env.throw(throwable) +} + pub(crate) struct ThrowCageforgeException; impl ErrorPolicy for ThrowCageforgeException { @@ -153,8 +258,15 @@ impl ErrorPolicy for ThrowCageforgeException { ) -> jni::errors::Result { if !env.exception_check() { let class = error.kind.class(env)?; - let message = jni::strings::JNIString::new(error.to_string()); - let _ = env.throw_new(class, message); + if let Some(diagnostic) = error.diagnostic.as_ref() { + if throw_configuration_exception(env, &class, &error.message, diagnostic).is_err() { + let message = jni::strings::JNIString::new(error.to_string()); + let _ = env.throw_new(class, message); + } + } else { + let message = jni::strings::JNIString::new(error.to_string()); + let _ = env.throw_new(class, message); + } } Ok(T::default()) } diff --git a/crates/bindings/cageforge-java/src/lib.rs b/crates/bindings/cageforge-java/src/lib.rs index 3c9bcfa5..9ae29f63 100644 --- a/crates/bindings/cageforge-java/src/lib.rs +++ b/crates/bindings/cageforge-java/src/lib.rs @@ -236,10 +236,8 @@ fn runtime_from_toml( let minimal_directory = minimal_directory .map(|value| path(value, "minimal directory")) .transpose()?; - let config = config_from_toml(&toml) - .map_err(|error| BindingError::new(BindingErrorKind::Configuration, error))?; - let profile = resolve_profile(&config, profile_name.as_deref()) - .map_err(|error| BindingError::new(BindingErrorKind::Configuration, error))?; + let config = config_from_toml(&toml)?; + let profile = resolve_profile(&config, profile_name.as_deref())?; let (context, effective, environment, ceiling) = runtime_inputs_with_ceiling(&profile, ¤t_directory, minimal_directory.as_deref()) .map_err(|error| BindingError::new(BindingErrorKind::Configuration, error))?; @@ -335,8 +333,8 @@ fn preflight_identity( )) } -fn config_from_toml(toml: &str) -> Result { - cageforge::Config::from_toml(toml).map_err(|error| error.to_string()) +fn config_from_toml(toml: &str) -> Result { + cageforge::Config::from_toml(toml).map_err(BindingError::configuration) } fn permission_request_from_toml( @@ -346,10 +344,8 @@ fn permission_request_from_toml( minimal_directory: Option<&Path>, identity: cageforge::PreflightIdentity, ) -> Result { - let config = config_from_toml(toml) - .map_err(|error| BindingError::new(BindingErrorKind::Configuration, error))?; - let profile = resolve_profile(&config, profile_name) - .map_err(|error| BindingError::new(BindingErrorKind::Configuration, error))?; + let config = config_from_toml(toml)?; + let profile = resolve_profile(&config, profile_name)?; let (context, effective, environment, ceiling) = runtime_inputs_with_ceiling(&profile, current_directory, minimal_directory) .map_err(|error| BindingError::new(BindingErrorKind::Configuration, error))?; @@ -376,13 +372,14 @@ fn permission_request_from_toml( fn resolve_profile( config: &cageforge::Config, profile_name: Option<&str>, -) -> Result { - let platform = cageforge::PlatformId::current().map_err(|error| error.to_string())?; +) -> Result { + let platform = cageforge::PlatformId::current() + .map_err(|error| BindingError::new(BindingErrorKind::Configuration, error.to_string()))?; match profile_name { Some(name) if !name.is_empty() => config.resolve_for_platform(name, platform), _ => config.resolve_default_for_platform(platform), } - .map_err(|error| error.to_string()) + .map_err(BindingError::configuration) } fn runtime_inputs( diff --git a/crates/bindings/cageforge-python/python/cageforge/_cageforge.pyi b/crates/bindings/cageforge-python/python/cageforge/_cageforge.pyi index b244ca6f..9b102243 100644 --- a/crates/bindings/cageforge-python/python/cageforge/_cageforge.pyi +++ b/crates/bindings/cageforge-python/python/cageforge/_cageforge.pyi @@ -106,7 +106,13 @@ class CageforgeConfigurationError(CageforgeError): r""" The TOML configuration or runtime context is invalid. """ - ... + code: builtins.str + config_path: typing.Optional[builtins.str] + profile: typing.Optional[builtins.str] + platform: typing.Optional[builtins.str] + field: typing.Optional[builtins.str] + line: typing.Optional[builtins.int] + column: typing.Optional[builtins.int] class CageforgeError(builtins.Exception): r""" diff --git a/crates/bindings/cageforge-python/src/bin/stub_gen.rs b/crates/bindings/cageforge-python/src/bin/stub_gen.rs index 15db3c5b..98efe7f4 100644 --- a/crates/bindings/cageforge-python/src/bin/stub_gen.rs +++ b/crates/bindings/cageforge-python/src/bin/stub_gen.rs @@ -21,6 +21,10 @@ fn main() -> pyo3_stub_gen::Result<()> { ) .replace("builtins.CageforgeStoreError", "CageforgeStoreError") .replace("os.PathLike", "os.PathLike[str]"); + let stub = stub.replace( + " ...\n\nclass CageforgeError(builtins.Exception):", + " code: builtins.str\n config_path: typing.Optional[builtins.str]\n profile: typing.Optional[builtins.str]\n platform: typing.Optional[builtins.str]\n field: typing.Optional[builtins.str]\n line: typing.Optional[builtins.int]\n column: typing.Optional[builtins.int]\n\nclass CageforgeError(builtins.Exception):", + ); let stub = format!("{}\n", stub.trim_end()); std::fs::write(target, stub)?; println!("wrote {}", target.display()); diff --git a/crates/bindings/cageforge-python/src/lib.rs b/crates/bindings/cageforge-python/src/lib.rs index 36c64ca5..20b470b3 100644 --- a/crates/bindings/cageforge-python/src/lib.rs +++ b/crates/bindings/cageforge-python/src/lib.rs @@ -348,6 +348,39 @@ fn configuration_error(error: impl ToString) -> PyErr { CageforgeConfigurationError::new_err(error.to_string()) } +fn configuration_diagnostic_error(error: cageforge::ConfigError) -> PyErr { + let diagnostic = error.diagnostic(); + let exception = CageforgeConfigurationError::new_err(diagnostic.render_human()); + Python::attach(|py| { + let value = exception.value(py); + let set_result = (|| -> PyResult<()> { + value.setattr("code", diagnostic.code())?; + value.setattr( + "config_path", + diagnostic + .config_path() + .map(|path| path.display().to_string()), + )?; + value.setattr("profile", diagnostic.profile())?; + value.setattr( + "platform", + diagnostic.platform().map(|platform| platform.as_str()), + )?; + value.setattr("field", diagnostic.field())?; + value.setattr("line", diagnostic.location().map(|location| location.line))?; + value.setattr( + "column", + diagnostic.location().map(|location| location.column), + )?; + Ok(()) + })(); + if let Err(attribute_error) = set_result { + return attribute_error; + } + exception + }) +} + fn initialization_error(error: impl ToString) -> PyErr { CageforgeInitializationError::new_err(error.to_string()) } @@ -763,8 +796,8 @@ fn module_native_directory(py: Python<'_>) -> PyResult { native_directory(&module) } -fn config_from_toml(toml: &str) -> Result { - cageforge::Config::from_toml(toml).map_err(|error| error.to_string()) +fn config_from_toml(toml: &str) -> Result { + cageforge::Config::from_toml(toml) } fn normalize_toml_source(toml: String) -> String { @@ -779,17 +812,18 @@ fn build_preflight_request( tool_version: String, manifest_digest: String, config_digest: String, -) -> Result { - let config = config_from_toml(toml)?; - let profile = resolve_profile(&config, profile_name)?; +) -> PyResult { + let config = config_from_toml(toml).map_err(configuration_diagnostic_error)?; + let profile = resolve_profile(&config, profile_name).map_err(configuration_diagnostic_error)?; let (resolution, effective, environment, ceiling) = - runtime_inputs_with_ceiling(&profile, &context.0, context.1.as_deref())?; + runtime_inputs_with_ceiling(&profile, &context.0, context.1.as_deref()) + .map_err(configuration_error)?; let identity = cageforge::PreflightIdentity::new( tool_id, tool_version, manifest_digest, config_digest, - cageforge::PlatformId::current().map_err(|error| error.to_string())?, + cageforge::PlatformId::current().map_err(|error| configuration_error(error.to_string()))?, std::env::consts::ARCH, ); let plan = cageforge::PreflightPlan::from_policy_with_ceiling( @@ -800,12 +834,12 @@ fn build_preflight_request( &ceiling, identity, ) - .map_err(|error| error.to_string())?; + .map_err(permission_error)?; if let Some(command) = profile.command() { return plan .with_process_program(command.command().program().to_string_lossy().into_owned()) .map(|plan| plan.request().clone()) - .map_err(|error| error.to_string()); + .map_err(permission_error); } Ok(plan.request().clone()) } @@ -837,13 +871,18 @@ fn preflight_identity( fn resolve_profile( config: &cageforge::Config, profile_name: Option<&str>, -) -> Result { - let platform = cageforge::PlatformId::current().map_err(|error| error.to_string())?; +) -> Result { + let platform = + cageforge::PlatformId::current().map_err(|error| cageforge::ConfigError::InvalidValue { + profile: profile_name.unwrap_or("default").to_owned(), + field: "platform".to_owned(), + value: error.to_string(), + context: None, + })?; match profile_name { Some(name) if !name.is_empty() => config.resolve_for_platform(name, platform), _ => config.resolve_default_for_platform(platform), } - .map_err(|error| error.to_string()) } fn command_request( @@ -910,7 +949,7 @@ impl Cageforge { if toml.is_empty() { return Err(configuration_error("TOML must not be empty")); } - let config = config_from_toml(&toml).map_err(configuration_error)?; + let config = config_from_toml(&toml).map_err(configuration_diagnostic_error)?; Ok(config.profile_names().map(str::to_owned).collect()) } @@ -943,8 +982,7 @@ impl Cageforge { tool_version.to_owned(), manifest_digest.unwrap_or_else(|| cageforge::sha256_digest(b"cageforge-python")), config_digest.unwrap_or_else(|| cageforge::sha256_digest(toml.as_bytes())), - ) - .map_err(configuration_error)?; + )?; Ok(PermissionRequest { inner: Some(request), }) @@ -968,9 +1006,9 @@ impl Cageforge { }); Python::attach(|py| { py.detach(move || { - let config = config_from_toml(&toml).map_err(configuration_error)?; + let config = config_from_toml(&toml).map_err(configuration_diagnostic_error)?; let profile = resolve_profile(&config, profile_name.as_deref()) - .map_err(configuration_error)?; + .map_err(configuration_diagnostic_error)?; runtime_inputs(&profile, &context.0, context.1.as_deref()) .map(|_| ()) .map_err(configuration_error) @@ -1019,9 +1057,9 @@ impl Cageforge { }) .transpose()?; let state = py.detach(move || { - let config = config_from_toml(&toml).map_err(configuration_error)?; - let profile = - resolve_profile(&config, profile_name.as_deref()).map_err(configuration_error)?; + let config = config_from_toml(&toml).map_err(configuration_diagnostic_error)?; + let profile = resolve_profile(&config, profile_name.as_deref()) + .map_err(configuration_diagnostic_error)?; let (resolution, effective, environment, ceiling) = runtime_inputs_with_ceiling(&profile, &context.0, context.1.as_deref()) .map_err(configuration_error)?; @@ -1119,7 +1157,17 @@ impl Cageforge { current_directory, minimal_path, }); - Self::from_toml(py, source, profile_name, context.as_ref(), grant, request) + let result = Self::from_toml(py, source, profile_name, context.as_ref(), grant, request); + if let Err(error) = &result + && error.is_instance_of::(py) + { + let value = error.value(py); + let config_path = value.getattr("config_path")?; + if config_path.is_none() { + value.setattr("config_path", file.to_string_lossy().as_ref())?; + } + } + result } /// Returns the native resource target selected by this interpreter. diff --git a/crates/bindings/cageforge-python/tests/test_native.py b/crates/bindings/cageforge-python/tests/test_native.py index b6ca3c42..041e05fd 100644 --- a/crates/bindings/cageforge-python/tests/test_native.py +++ b/crates/bindings/cageforge-python/tests/test_native.py @@ -14,6 +14,7 @@ import pytest from cageforge import ( Cageforge, + CageforgeConfigurationError, CageforgeError, CageforgeEscalationError, CageforgeInvalidCursorError, @@ -94,6 +95,40 @@ def runtime_context(tmp_path: Path) -> RuntimeContext: return RuntimeContext(tmp_path, minimal_path) +def test_configuration_errors_expose_stable_source_fields() -> None: + source = ( + 'default_profile = "broken"\n\n' + '[profiles.broken]\n' + 'workspace_roots = { "../outside" = true }\n' + ) + + with pytest.raises(CageforgeConfigurationError) as caught: + Cageforge.check_toml(source) + + error = caught.value + assert error.code == "invalid_value" + assert error.profile == "broken" + assert error.field == "workspace_roots" + assert error.config_path is None + assert error.line == 4 + assert error.column == 1 + + +def test_configuration_errors_from_file_keep_the_config_path(tmp_path: Path) -> None: + config = tmp_path / "broken.toml" + config.write_text( + 'default_profile = "broken"\n\n' + '[profiles.broken]\n' + 'workspace_roots = { "../outside" = true }\n', + encoding="utf-8", + ) + + with pytest.raises(CageforgeConfigurationError) as caught: + Cageforge.from_toml_file(config) + + assert caught.value.config_path == str(config.resolve()) + + def smoke_argv() -> list[str]: if sys.platform == "win32": return [ diff --git a/crates/cageforge-cli/src/error.rs b/crates/cageforge-cli/src/error.rs index d27fb474..acf326fd 100644 --- a/crates/cageforge-cli/src/error.rs +++ b/crates/cageforge-cli/src/error.rs @@ -113,4 +113,15 @@ impl CliError { pub(crate) const fn exit_code(&self) -> u8 { 2 } + + pub(crate) fn render(&self) -> String { + match self { + #[cfg(feature = "config")] + Self::Config(error) => format!( + "configuration error:\n{}", + error.diagnostic().render_human() + ), + _ => self.to_string(), + } + } } diff --git a/crates/cageforge-cli/src/execution.rs b/crates/cageforge-cli/src/execution.rs index 56b5877d..e230a548 100644 --- a/crates/cageforge-cli/src/execution.rs +++ b/crates/cageforge-cli/src/execution.rs @@ -138,6 +138,7 @@ fn execute_run(args: RunArgs) -> Result { profile: args.profile.clone().unwrap_or_else(|| "default".to_owned()), field: "platform".to_owned(), value: error.to_string(), + context: None, }) })?; let profile = match args.profile.as_deref() { diff --git a/crates/cageforge-cli/src/lib.rs b/crates/cageforge-cli/src/lib.rs index ad524112..c49cbb0f 100644 --- a/crates/cageforge-cli/src/lib.rs +++ b/crates/cageforge-cli/src/lib.rs @@ -54,7 +54,7 @@ pub fn run() -> ExitCode { match execute(cli) { Ok(code) => ExitCode::from(code), Err(error) => { - let _ = writeln!(stderr, "cageforge-cli: {error}"); + let _ = writeln!(stderr, "cageforge-cli: {}", error.render()); ExitCode::from(error.exit_code()) } } diff --git a/crates/cageforge-cli/tests/cli.rs b/crates/cageforge-cli/tests/cli.rs index 0dee0beb..cd2ae1c7 100644 --- a/crates/cageforge-cli/tests/cli.rs +++ b/crates/cageforge-cli/tests/cli.rs @@ -187,6 +187,32 @@ fn permission_revoke_validation_error_is_snapshotted() { insta::assert_snapshot!(String::from_utf8_lossy(&output.stderr)); } +#[test] +fn configuration_error_is_rendered_with_source_context() { + let workspace = tempfile::tempdir().expect("temporary configuration directory"); + let config = workspace.path().join("config.toml"); + std::fs::write( + &config, + "default_profile = \"broken\"\n\n[profiles.broken]\nworkspace_roots = { \"../outside\" = true }\n", + ) + .expect("write invalid configuration"); + + let output = ProcessCommand::new(env!("CARGO_BIN_EXE_cageforge-cli")) + .args([ + "run", + "--config", + config.to_str().expect("UTF-8 config path"), + "--", + "/bin/true", + ]) + .output() + .expect("run cageforge-cli with invalid configuration"); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr) + .replace(&config.display().to_string(), ""); + insta::assert_snapshot!(stderr); +} + #[test] fn no_command_is_a_usage_error() { let output = ProcessCommand::new(env!("CARGO_BIN_EXE_cageforge-cli")) diff --git a/crates/cageforge-cli/tests/snapshots/cli__configuration_error_is_rendered_with_source_context.snap b/crates/cageforge-cli/tests/snapshots/cli__configuration_error_is_rendered_with_source_context.snap new file mode 100644 index 00000000..c407c2b2 --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__configuration_error_is_rendered_with_source_context.snap @@ -0,0 +1,9 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: stderr +--- +cageforge-cli: configuration error: +[invalid_value] profile "broken" has invalid workspace_roots: path must not contain parent traversal + --> :4:1 + profile: broken + field: workspace_roots diff --git a/crates/cageforge-config/src/build/command.rs b/crates/cageforge-config/src/build/command.rs index 8fbd688b..97a36ff0 100644 --- a/crates/cageforge-config/src/build/command.rs +++ b/crates/cageforge-config/src/build/command.rs @@ -27,10 +27,12 @@ pub(crate) fn build_command( .as_deref() .ok_or_else(|| ConfigError::MissingCommandProgram { profile: profile.to_owned(), + context: None, })?; let mut command = CommandSpec::new(program).map_err(|source| ConfigError::Command { profile: profile.to_owned(), source, + context: None, })?; if let Some(args) = &raw.args { command = command @@ -38,6 +40,7 @@ pub(crate) fn build_command( .map_err(|source| ConfigError::Command { profile: profile.to_owned(), source, + context: None, })?; } let mut request = CommandRequest::new(command); @@ -47,6 +50,7 @@ pub(crate) fn build_command( .map_err(|source| ConfigError::Command { profile: profile.to_owned(), source, + context: None, })?; } if let Some(environment) = &raw.environment { @@ -78,6 +82,7 @@ fn build_environment(raw: &RawEnvironment, profile: &str) -> Result Result Result ConfigError { profile: profile.to_owned(), field: field.to_owned(), value: format!("{value} must be representable and greater than zero"), + context: None, } } @@ -128,5 +129,6 @@ fn gateway_error(profile: &str, source: GatewayConfigError) -> ConfigError { ConfigError::NetworkGateway { profile: profile.to_owned(), source, + context: None, } } diff --git a/crates/cageforge-config/src/build/policy.rs b/crates/cageforge-config/src/build/policy.rs index 54f7667a..1721d141 100644 --- a/crates/cageforge-config/src/build/policy.rs +++ b/crates/cageforge-config/src/build/policy.rs @@ -30,6 +30,7 @@ pub(crate) fn build_policy( policy.validate().map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; Ok(policy) } @@ -52,6 +53,7 @@ fn build_filesystem( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; } for path in &raw.additional_protected_paths { @@ -60,6 +62,7 @@ fn build_filesystem( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; } if raw @@ -83,11 +86,13 @@ fn build_filesystem( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; } policy.validate().map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; Ok(policy) } @@ -154,6 +159,7 @@ fn build_filesystem_rule( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?, ) } @@ -174,6 +180,7 @@ fn build_filesystem_rule( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?, ) } @@ -189,6 +196,7 @@ fn build_filesystem_rule( FilesystemRule::from_target(target, access).map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; if let Some(missing_path) = &raw.missing_path { let behavior = match missing_path { @@ -207,6 +215,7 @@ fn build_filesystem_rule( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; } Ok(rule) @@ -264,6 +273,7 @@ fn build_selector( result.map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, }) } @@ -305,6 +315,7 @@ fn build_network( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; } for RawUnixSocketRule { path, access } in &raw.unix_sockets { @@ -313,6 +324,7 @@ fn build_network( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; } if let Some(local_ipc) = local_ipc { @@ -323,6 +335,7 @@ fn build_network( ConfigError::Policy { profile: profile.to_owned(), source, + context: None, } })?, DomainAccess::Allow, @@ -330,6 +343,7 @@ fn build_network( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; } for name in &local_ipc.named_pipes { @@ -339,6 +353,7 @@ fn build_network( ConfigError::Policy { profile: profile.to_owned(), source, + context: None, } })?, DomainAccess::Allow, @@ -346,12 +361,14 @@ fn build_network( .map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; } } policy.validate().map_err(|source| ConfigError::Policy { profile: profile.to_owned(), source, + context: None, })?; Ok(policy) } diff --git a/crates/cageforge-config/src/diagnostics.rs b/crates/cageforge-config/src/diagnostics.rs index 3cb55f80..b8a64113 100644 --- a/crates/cageforge-config/src/diagnostics.rs +++ b/crates/cageforge-config/src/diagnostics.rs @@ -7,6 +7,7 @@ use crate::{ConfigError, SourceLocation}; use serde::Serialize; +use std::path::Path; /// Severity of a configuration diagnostic. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -28,6 +29,10 @@ pub struct ConfigDiagnostic { field: Option, #[serde(skip_serializing_if = "Option::is_none")] location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + config_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + platform: Option, } impl ConfigDiagnostic { @@ -61,22 +66,55 @@ impl ConfigDiagnostic { self.location } + /// Returns the TOML path associated with the diagnostic. + pub fn config_path(&self) -> Option<&Path> { + self.config_path.as_deref() + } + + /// Returns the selected platform overlay, when one was resolved. + pub const fn platform(&self) -> Option { + self.platform + } + /// Serializes this diagnostic as a JSON object. pub fn to_json(&self) -> Result { serde_json::to_string(self) } + + /// Renders one concise human-readable diagnostic for command-line tools. + pub fn render_human(&self) -> String { + let source = self.config_path().map_or_else( + || "".to_owned(), + |path| path.display().to_string(), + ); + let location = self.location.map_or_else( + || source.clone(), + |location| format!("{source}:{}:{}", location.line, location.column), + ); + let mut output = format!("[{}] {}\n --> {location}", self.code, self.message); + if let Some(profile) = self.profile.as_deref() { + output.push_str(&format!("\n profile: {profile}")); + } + if let Some(platform) = self.platform { + output.push_str(&format!("\n platform: {}", platform.as_str())); + } + if let Some(field) = self.field.as_deref() { + output.push_str(&format!("\n field: {field}")); + } + output + } } impl ConfigError { /// Converts this typed error into a stable diagnostic for UIs, CLIs, or /// structured application logs. pub fn diagnostic(&self) -> ConfigDiagnostic { - let (code, profile, field, location) = match self { + let (code, profile, field, intrinsic_location) = match self { Self::InvalidToml { location, .. } => ("invalid_toml", None, None, *location), Self::ReadFile { .. } => ("config_file_unreadable", None, None, None), Self::InvalidProfileName { .. } => ("invalid_profile_name", None, None, None), Self::UnknownProfile { .. } => ("unknown_profile", None, None, None), - Self::NoDefaultProfile => ("missing_default_profile", None, None, None), + Self::NoDefaultProfile { .. } => ("missing_default_profile", None, None, None), Self::ResolutionInvariant { .. } => ("profile_resolution_invariant", None, None, None), Self::ProfileCycle { .. } => ("profile_inheritance_cycle", None, None, None), Self::InvalidValue { profile, field, .. } => ( @@ -85,7 +123,7 @@ impl ConfigError { Some(field.clone()), None, ), - Self::MissingCommandProgram { profile } => { + Self::MissingCommandProgram { profile, .. } => { ("missing_command_program", Some(profile.clone()), None, None) } Self::Policy { profile, .. } => ("invalid_policy", Some(profile.clone()), None, None), @@ -97,13 +135,18 @@ impl ConfigError { None, ), }; + let context = self.context(); ConfigDiagnostic { code, severity: DiagnosticSeverity::Error, message: self.to_string(), profile, field, - location, + location: context + .and_then(|value| value.location) + .or(intrinsic_location), + config_path: context.and_then(|value| value.config_path.clone()), + platform: context.and_then(|value| value.platform), } } } diff --git a/crates/cageforge-config/src/error.rs b/crates/cageforge-config/src/error.rs index 1b5d93bd..762fb8a1 100644 --- a/crates/cageforge-config/src/error.rs +++ b/crates/cageforge-config/src/error.rs @@ -7,6 +7,7 @@ use cageforge_command::CommandError; use cageforge_network_proxy::GatewayConfigError; +use cageforge_permissions::PlatformId; use cageforge_policy::PolicyError; use serde::Serialize; use std::path::PathBuf; @@ -25,6 +26,22 @@ pub struct SourceLocation { pub length: usize, } +/// Context attached to a configuration failure after the source document has +/// been identified. The context is diagnostic metadata; it never changes the +/// policy value that failed validation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConfigErrorContext { + /// The TOML file, when the document came from a file. + #[serde(skip_serializing_if = "Option::is_none")] + pub config_path: Option, + /// The platform overlay being resolved, when one was selected. + #[serde(skip_serializing_if = "Option::is_none")] + pub platform: Option, + /// The source span for the logical field, when it can be located. + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + /// Errors returned while parsing or resolving a Cageforge configuration. #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum ConfigError { @@ -35,6 +52,8 @@ pub enum ConfigError { message: String, /// The parser span, when TOML provided one. location: Option, + /// File and platform context for this diagnostic. + context: Option>, }, /// The configuration file could not be read. #[error("cannot read configuration {}: {message}", path.display())] @@ -43,22 +62,31 @@ pub enum ConfigError { path: PathBuf, /// The I/O error description. message: String, + /// File and platform context for this diagnostic. + context: Option>, }, /// A profile name is not a safe configuration identifier. #[error("invalid profile name: {name:?}")] InvalidProfileName { /// The invalid profile name. name: String, + /// File and platform context for this diagnostic. + context: Option>, }, /// A referenced profile does not exist. #[error("unknown profile: {name}")] UnknownProfile { /// The missing profile name. name: String, + /// File and platform context for this diagnostic. + context: Option>, }, /// `resolve_default` was requested without a configured default profile. #[error("no default profile is configured")] - NoDefaultProfile, + NoDefaultProfile { + /// File and platform context for this diagnostic. + context: Option>, + }, /// The iterative inheritance resolver reached an impossible internal /// state. This is returned instead of panicking so callers still receive /// a typed configuration failure if the resolver is changed incorrectly. @@ -66,12 +94,16 @@ pub enum ConfigError { ResolutionInvariant { /// Stable explanation of the violated resolver invariant. message: &'static str, + /// File and platform context for this diagnostic. + context: Option>, }, /// Profile inheritance contains a cycle. #[error("profile inheritance cycle: {}", chain.join(" -> "))] ProfileCycle { /// The cycle path, including the repeated profile at the end. chain: Vec, + /// File and platform context for this diagnostic. + context: Option>, }, /// A profile field contains an invalid or incomplete value. #[error("profile {profile:?} has invalid {field}: {value}")] @@ -82,12 +114,16 @@ pub enum ConfigError { field: String, /// The supplied value or an explanation of what is missing. value: String, + /// File and platform context for this diagnostic. + context: Option>, }, /// A command profile did not provide a program after inheritance. #[error("profile {profile:?} command has no program")] MissingCommandProgram { /// The profile containing the incomplete command. profile: String, + /// File and platform context for this diagnostic. + context: Option>, }, /// The policy model rejected a resolved profile value. #[error("profile {profile:?} has an invalid policy: {source}")] @@ -97,6 +133,8 @@ pub enum ConfigError { /// The policy validation error. #[source] source: PolicyError, + /// File and platform context for this diagnostic. + context: Option>, }, /// The command model rejected a resolved profile value. #[error("profile {profile:?} has an invalid command: {source}")] @@ -106,6 +144,8 @@ pub enum ConfigError { /// The command validation error. #[source] source: CommandError, + /// File and platform context for this diagnostic. + context: Option>, }, /// The network gateway rejected a resolved runtime setting. #[error("profile {profile:?} has an invalid network gateway: {source}")] @@ -115,13 +155,178 @@ pub enum ConfigError { /// Gateway validation error. #[source] source: GatewayConfigError, + /// File and platform context for this diagnostic. + context: Option>, }, } +impl ConfigError { + pub(crate) fn profile_field(&self) -> (Option<&str>, Option<&str>) { + match self { + Self::InvalidToml { .. } | Self::ReadFile { .. } => (None, None), + Self::InvalidProfileName { name, .. } => (Some(name), None), + Self::UnknownProfile { name, .. } => (Some(name), None), + Self::NoDefaultProfile { .. } => (None, None), + Self::ResolutionInvariant { .. } => (None, None), + Self::ProfileCycle { chain, .. } => { + (chain.first().map(String::as_str), Some("inherits")) + } + Self::InvalidValue { profile, field, .. } => (Some(profile), Some(field)), + Self::MissingCommandProgram { profile, .. } => (Some(profile), Some("command.program")), + Self::Policy { profile, .. } => (Some(profile), Some("filesystem")), + Self::Command { profile, .. } => (Some(profile), Some("command")), + Self::NetworkGateway { profile, .. } => (Some(profile), Some("network.gateway")), + } + } + + /// Adds source-file and selected-platform metadata without changing the + /// typed error category or its nested source. + pub fn with_context(self, context: ConfigErrorContext) -> Self { + fn merge( + existing: Option>, + incoming: ConfigErrorContext, + ) -> Option> { + let mut result = existing.map(|value| *value).unwrap_or(ConfigErrorContext { + config_path: None, + platform: None, + location: None, + }); + if incoming.config_path.is_some() { + result.config_path = incoming.config_path; + } + if incoming.platform.is_some() { + result.platform = incoming.platform; + } + if incoming.location.is_some() { + result.location = incoming.location; + } + (result.config_path.is_some() || result.platform.is_some() || result.location.is_some()) + .then(|| Box::new(result)) + } + + match self { + Self::InvalidToml { + message, + location, + context: existing, + } => Self::InvalidToml { + message, + location, + context: merge(existing, context), + }, + Self::ReadFile { + path, + message, + context: existing, + } => Self::ReadFile { + path, + message, + context: merge(existing, context), + }, + Self::InvalidProfileName { + name, + context: existing, + } => Self::InvalidProfileName { + name, + context: merge(existing, context), + }, + Self::UnknownProfile { + name, + context: existing, + } => Self::UnknownProfile { + name, + context: merge(existing, context), + }, + Self::NoDefaultProfile { context: existing } => Self::NoDefaultProfile { + context: merge(existing, context), + }, + Self::ResolutionInvariant { + message, + context: existing, + } => Self::ResolutionInvariant { + message, + context: merge(existing, context), + }, + Self::ProfileCycle { + chain, + context: existing, + } => Self::ProfileCycle { + chain, + context: merge(existing, context), + }, + Self::InvalidValue { + profile, + field, + value, + context: existing, + } => Self::InvalidValue { + profile, + field, + value, + context: merge(existing, context), + }, + Self::MissingCommandProgram { + profile, + context: existing, + } => Self::MissingCommandProgram { + profile, + context: merge(existing, context), + }, + Self::Policy { + profile, + source, + context: existing, + } => Self::Policy { + profile, + source, + context: merge(existing, context), + }, + Self::Command { + profile, + source, + context: existing, + } => Self::Command { + profile, + source, + context: merge(existing, context), + }, + Self::NetworkGateway { + profile, + source, + context: existing, + } => Self::NetworkGateway { + profile, + source, + context: merge(existing, context), + }, + } + } + + /// Returns the attached diagnostic context, if the error was produced by + /// a source-aware configuration entry point. + pub fn context(&self) -> Option<&ConfigErrorContext> { + match self { + Self::InvalidToml { context, .. } + | Self::ReadFile { context, .. } + | Self::InvalidProfileName { context, .. } + | Self::UnknownProfile { context, .. } + | Self::NoDefaultProfile { context } + | Self::ResolutionInvariant { context, .. } + | Self::ProfileCycle { context, .. } + | Self::InvalidValue { context, .. } + | Self::MissingCommandProgram { context, .. } + | Self::Policy { context, .. } + | Self::Command { context, .. } + | Self::NetworkGateway { context, .. } => context.as_deref(), + } + } +} + pub(crate) fn invalid_value(profile: &str, field: &str, value: impl Into) -> ConfigError { ConfigError::InvalidValue { profile: profile.to_owned(), field: field.to_owned(), value: value.into(), + context: None, } } diff --git a/crates/cageforge-config/src/lib.rs b/crates/cageforge-config/src/lib.rs index 024fcc2b..b5f697d8 100644 --- a/crates/cageforge-config/src/lib.rs +++ b/crates/cageforge-config/src/lib.rs @@ -32,7 +32,7 @@ mod resolve; mod schema; pub use diagnostics::{ConfigDiagnostic, DiagnosticSeverity}; -pub use error::{ConfigError, SourceLocation}; +pub use error::{ConfigError, ConfigErrorContext, SourceLocation}; pub use resolve::{Config, ResolvedProfile}; pub use schema::config_schema_json; diff --git a/crates/cageforge-config/src/resolve.rs b/crates/cageforge-config/src/resolve.rs index 90584f53..fbaf8a2d 100644 --- a/crates/cageforge-config/src/resolve.rs +++ b/crates/cageforge-config/src/resolve.rs @@ -7,7 +7,7 @@ //! layers. Runtime path discovery remains outside this module. use crate::build; -use crate::error::{ConfigError, invalid_value}; +use crate::error::{ConfigError, ConfigErrorContext, SourceLocation, invalid_value}; use crate::merge::{ MergedProfile, ProfileMerger, domain_rule_key, environment_filter_key, filesystem_rule_key, @@ -28,6 +28,13 @@ use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct Config { raw: RawConfig, + source: SourceDocument, +} + +#[derive(Debug, Clone)] +struct SourceDocument { + text: String, + path: Option, } /// A fully resolved profile ready for a backend or harness adapter. @@ -50,12 +57,7 @@ struct ResolveFrame { impl Config { /// Parses a strict Cageforge TOML document. pub fn from_toml(source: &str) -> Result { - let raw = toml::from_str(source).map_err(|error| ConfigError::InvalidToml { - message: error.to_string(), - location: error.span().map(|span| source_location(source, span)), - })?; - validate_raw_config(&raw)?; - Ok(Self { raw }) + Self::from_source(source.to_owned(), None) } /// Reads and parses a Cageforge TOML document from a file. @@ -64,8 +66,44 @@ impl Config { let source = std::fs::read_to_string(&path).map_err(|error| ConfigError::ReadFile { path: path.clone(), message: error.to_string(), + context: Some(Box::new(ConfigErrorContext { + config_path: Some(path.clone()), + platform: None, + location: None, + })), })?; - Self::from_toml(&source) + Self::from_source(source, Some(path)) + } + + fn from_source(source: String, path: Option) -> Result { + let raw = toml::from_str(&source).map_err(|error| ConfigError::InvalidToml { + message: error.to_string(), + location: error.span().map(|span| source_location(&source, span)), + context: Some(Box::new(ConfigErrorContext { + config_path: path.clone(), + platform: None, + location: error.span().map(|span| source_location(&source, span)), + })), + })?; + let config = Self { + raw, + source: SourceDocument { text: source, path }, + }; + validate_raw_config(&config.raw).map_err(|error| config.decorate(error, None))?; + Ok(config) + } + + fn decorate(&self, error: ConfigError, platform: Option) -> ConfigError { + let (profile, field) = error.profile_field(); + let profile = profile.map(str::to_owned); + let field = field.map(str::to_owned); + error.with_context(ConfigErrorContext { + config_path: self.source.path.clone(), + platform, + location: self + .source + .location_for(profile.as_deref(), platform, field.as_deref()), + }) } /// Returns profile names in deterministic lexical order. @@ -96,6 +134,15 @@ impl Config { &self, name: &str, platform: Option, + ) -> Result { + self.resolve_with_platform_inner(name, platform) + .map_err(|error| self.decorate(error, platform)) + } + + fn resolve_with_platform_inner( + &self, + name: &str, + platform: Option, ) -> Result { let merged = self.resolve_raw(name, platform)?; let policy = build::build_policy( @@ -120,7 +167,13 @@ impl Config { let executable_roots = merged .runtime .as_ref() - .map(|runtime| runtime.executable_roots.iter().map(PathBuf::from).collect()) + .map(|runtime| { + runtime + .executable_roots + .iter() + .map(PathBuf::from) + .collect::>() + }) .unwrap_or_default(); let approval = build_approval(merged.approval.as_ref(), name)?; Ok(ResolvedProfile { @@ -138,7 +191,7 @@ impl Config { pub fn resolve_default(&self) -> Result { let name = self .default_profile_name() - .ok_or(ConfigError::NoDefaultProfile)?; + .ok_or_else(|| self.decorate(ConfigError::NoDefaultProfile { context: None }, None))?; self.resolve(name) } @@ -147,9 +200,12 @@ impl Config { &self, platform: PlatformId, ) -> Result { - let name = self - .default_profile_name() - .ok_or(ConfigError::NoDefaultProfile)?; + let name = self.default_profile_name().ok_or_else(|| { + self.decorate( + ConfigError::NoDefaultProfile { context: None }, + Some(platform), + ) + })?; self.resolve_for_platform(name, platform) } @@ -161,6 +217,7 @@ impl Config { if !self.raw.profiles.contains_key(name) { return Err(ConfigError::UnknownProfile { name: name.to_owned(), + context: None, }); } @@ -181,6 +238,7 @@ impl Config { .get(&frame_name) .ok_or_else(|| ConfigError::UnknownProfile { name: frame_name.clone(), + context: None, })?; if let Some(parent_name) = profile @@ -195,10 +253,16 @@ impl Config { if let Some(start) = active.get(&parent_name) { let mut chain = active_names[*start..].to_vec(); chain.push(parent_name); - return Err(ConfigError::ProfileCycle { chain }); + return Err(ConfigError::ProfileCycle { + chain, + context: None, + }); } if !self.raw.profiles.contains_key(&parent_name) { - return Err(ConfigError::UnknownProfile { name: parent_name }); + return Err(ConfigError::UnknownProfile { + name: parent_name, + context: None, + }); } active.insert(parent_name.clone(), active_names.len()); active_names.push(parent_name.clone()); @@ -209,6 +273,7 @@ impl Config { let Some(frame) = frames.pop() else { return Err(ConfigError::ResolutionInvariant { message: "resolution stack was empty while completing a profile", + context: None, }); }; active.remove(&frame.name); @@ -223,7 +288,10 @@ impl Config { let mut merger = ProfileMerger::new(path_dialect(platform)); for profile_name in order { let Some(profile) = self.raw.profiles.get(&profile_name) else { - return Err(ConfigError::UnknownProfile { name: profile_name }); + return Err(ConfigError::UnknownProfile { + name: profile_name, + context: None, + }); }; merger.apply(profile, platform); } @@ -231,6 +299,62 @@ impl Config { } } +impl SourceDocument { + fn location_for( + &self, + profile: Option<&str>, + platform: Option, + field: Option<&str>, + ) -> Option { + let profile = profile?; + let base_header = match platform { + Some(platform) => format!("[profiles.{profile}.platforms.{}]", platform.as_str()), + None => format!("[profiles.{profile}]"), + }; + let field_prefix = field.and_then(|value| value.rsplit_once('.').map(|(prefix, _)| prefix)); + let nested_header = field_prefix.map(|prefix| format!("{base_header}.{prefix}")); + let headers = nested_header + .as_deref() + .into_iter() + .chain(std::iter::once(base_header.as_str())); + let mut section_start = None; + let mut offset = 0; + for line in self.text.split_inclusive('\n') { + let current = line.trim().trim_end_matches('\n'); + if headers.clone().any(|header| current == header) { + section_start = Some(offset); + break; + } + offset += line.len(); + } + let section_start = section_start?; + let section = &self.text[section_start..]; + let key = field + .and_then(|value| value.rsplit('.').next()) + .unwrap_or_default(); + let mut line_offset = section_start; + for line in section.split_inclusive('\n') { + if line_offset != section_start && line.trim_start().starts_with('[') { + break; + } + let trimmed = line.trim_start(); + if !key.is_empty() + && trimmed.starts_with(key) + && trimmed[key.len()..].trim_start().starts_with('=') + { + let start = line_offset + line.len() - line.trim_start().len(); + let end = start + key.len(); + return Some(source_location(&self.text, start..end)); + } + line_offset += line.len(); + } + Some(source_location( + &self.text, + section_start..section_start + base_header.len(), + )) + } +} + impl ResolvedProfile { /// Returns the selected profile's description, if configured. pub fn description(&self) -> Option<&str> { @@ -288,6 +412,7 @@ fn validate_raw_config(config: &RawConfig) -> Result<(), ConfigError> { if !config.profiles.contains_key(default_profile) { return Err(ConfigError::UnknownProfile { name: default_profile.clone(), + context: None, }); } } @@ -476,6 +601,7 @@ fn validate_profile_name(name: &str) -> Result<(), ConfigError> { } else { Err(ConfigError::InvalidProfileName { name: name.to_owned(), + context: None, }) } } diff --git a/crates/cageforge-config/tests/config.rs b/crates/cageforge-config/tests/config.rs index ac1221f8..1908f691 100644 --- a/crates/cageforge-config/tests/config.rs +++ b/crates/cageforge-config/tests/config.rs @@ -244,6 +244,7 @@ dns_timeout_ms = 0 profile: "safe".to_owned(), field: "network.gateway.dns_timeout_ms".to_owned(), value: "0 must be representable and greater than zero".to_owned(), + context: None, } ); @@ -264,6 +265,7 @@ http_header_bytes = 4096 minimum: 8192, actual: 4096, }, + context: None, } ); @@ -1095,7 +1097,7 @@ fn rejects_unknown_fields_and_invalid_profile_names() { assert!(matches!(error, ConfigError::InvalidToml { .. })); let error = Config::from_toml("[profiles.\"bad.name\"]\n").expect_err("invalid profile name"); - assert!(matches!(error, ConfigError::InvalidProfileName { name } if name == "bad.name")); + assert!(matches!(error, ConfigError::InvalidProfileName { name, .. } if name == "bad.name")); let error = Config::from_toml( r#" @@ -1215,7 +1217,7 @@ unix_sockets = [ fn rejects_inheritance_and_default_errors() { let error = Config::from_toml("default_profile = \"missing\"\n[profiles.safe]\n") .expect_err("unknown default profile"); - assert!(matches!(error, ConfigError::UnknownProfile { name } if name == "missing")); + assert!(matches!(error, ConfigError::UnknownProfile { name, .. } if name == "missing")); let error = Config::from_toml( "[profiles.safe]\ninherits = [\"parent\", \"parent\"]\n[profiles.parent]\n", @@ -1237,7 +1239,7 @@ inherits = ["a"] .expect("parse cycle"); assert!(matches!( config.resolve("a"), - Err(ConfigError::ProfileCycle { chain }) if chain == ["a", "b", "a"] + Err(ConfigError::ProfileCycle { chain, .. }) if chain == ["a", "b", "a"] )); let config = Config::from_toml( @@ -1249,17 +1251,17 @@ inherits = ["missing"] .expect("parse unknown parent"); assert!(matches!( config.resolve("safe"), - Err(ConfigError::UnknownProfile { name }) if name == "missing" + Err(ConfigError::UnknownProfile { name, .. }) if name == "missing" )); let config = Config::from_toml("[profiles.safe]\n").expect("profile"); assert!(matches!( config.resolve_default(), - Err(ConfigError::NoDefaultProfile) + Err(ConfigError::NoDefaultProfile { .. }) )); assert!(matches!( config.resolve("missing"), - Err(ConfigError::UnknownProfile { name }) if name == "missing" + Err(ConfigError::UnknownProfile { name, .. }) if name == "missing" )); } @@ -1656,36 +1658,45 @@ fn config_errors_have_context_and_sources() { ConfigError::InvalidToml { message: "bad syntax".to_owned(), location: None, + context: None, }, ConfigError::ReadFile { path: PathBuf::from("config.toml"), message: "missing".to_owned(), + context: None, }, ConfigError::InvalidProfileName { name: "bad.name".to_owned(), + context: None, }, ConfigError::UnknownProfile { name: "missing".to_owned(), + context: None, }, - ConfigError::NoDefaultProfile, + ConfigError::NoDefaultProfile { context: None }, ConfigError::ProfileCycle { chain: vec!["a".to_owned(), "b".to_owned(), "a".to_owned()], + context: None, }, ConfigError::InvalidValue { profile: "safe".to_owned(), field: "filesystem.mode".to_owned(), value: "bad".to_owned(), + context: None, }, ConfigError::MissingCommandProgram { profile: "safe".to_owned(), + context: None, }, ConfigError::Policy { profile: "safe".to_owned(), source: cageforge_policy::PolicyError::EmptyPath, + context: None, }, ConfigError::Command { profile: "safe".to_owned(), source: cageforge_command::CommandError::EmptyProgram, + context: None, }, ]; for error in &errors { @@ -1715,14 +1726,20 @@ fn config_errors_have_context_and_sources() { let policy = ConfigError::Policy { profile: "safe".to_owned(), source: cageforge_policy::PolicyError::EmptyPath, + context: None, }; assert!(policy.source().is_some()); let command = ConfigError::Command { profile: "safe".to_owned(), source: cageforge_command::CommandError::EmptyProgram, + context: None, }; assert!(command.source().is_some()); - assert!(ConfigError::NoDefaultProfile.source().is_none()); + assert!( + ConfigError::NoDefaultProfile { context: None } + .source() + .is_none() + ); } #[test] diff --git a/crates/cageforge-config/tests/platforms.rs b/crates/cageforge-config/tests/platforms.rs index 20115990..c27b408f 100644 --- a/crates/cageforge-config/tests/platforms.rs +++ b/crates/cageforge-config/tests/platforms.rs @@ -195,6 +195,9 @@ default_profile = "tool" [profiles.tool.platforms.macos.runtime] executable_roots = ["/opt/example-runtime"] + +[profiles.tool.platforms.macos.filesystem] +rules = [{ target = "absolute", path = "/opt/example-runtime", access = "read" }] "#, ) .expect("runtime-root configuration"); @@ -224,6 +227,9 @@ default_profile = "tool" [profiles.tool.platforms.macos.runtime] executable_roots = ["/opt/example-runtime"] +[profiles.tool.platforms.macos.filesystem] +rules = [{ target = "absolute", path = "/opt/example-runtime", access = "read" }] + [profiles.tool.platforms.windows.runtime] executable_roots = ["C:/Program Files/example-runtime"] "#, diff --git a/crates/cageforge-macos/src/backend.rs b/crates/cageforge-macos/src/backend.rs index 15d3823a..51eea5c3 100644 --- a/crates/cageforge-macos/src/backend.rs +++ b/crates/cageforge-macos/src/backend.rs @@ -5,19 +5,23 @@ use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; use std::fs; +use std::path::Path; use cageforge_backend_api::{ BackendCapabilities, BackendCapability, BackendIdentity, BackendRequest, PreparedBackendRequest, Sandbox, SandboxBackend, }; use cageforge_command::{CoreEnvironment, EnvironmentBase, EnvironmentInput}; +use cageforge_path::is_within; +use cageforge_policy::FilesystemDecision; use cageforge_policy::PathResolutionContext; use crate::config::MacosBackendConfig; -use crate::error::MacosBackendError; +use crate::error::{MacosBackendError, MacosFilesystemError}; use crate::filesystem::MacosFilesystemPlan; use crate::network::{GatewayRuntime, MacosNetworkPlan}; use crate::process::{MacosChild, launchd}; +use crate::runtime::is_fixed_executable; use crate::seatbelt::SeatbeltProfile; /// A macOS-native backend bound to one validated Seatbelt executable. @@ -75,11 +79,46 @@ impl MacosBackend { context: &PathResolutionContext, ) -> Result, MacosBackendError> { let prepared = request.prepare_for(self, context)?; + self.validate_program(&prepared)?; MacosFilesystemPlan::lower(self, &prepared)?; MacosNetworkPlan::lower(self, &prepared)?; Ok(prepared) } + fn validate_program<'a>( + &self, + prepared: &PreparedBackendRequest<'a, Self>, + ) -> Result<(), MacosBackendError> { + let program = prepared.command_spec(self)?.program(); + if !Path::new(program).is_absolute() { + return Ok(()); + } + let program = Path::new(program).to_path_buf(); + if is_fixed_executable(&program) { + return Ok(()); + } + match prepared.filesystem_access_for_path(self, &program)? { + FilesystemDecision::Read | FilesystemDecision::Write => {} + FilesystemDecision::Deny => { + return Err(MacosFilesystemError::ProgramRequiresRead { path: program }.into()); + } + FilesystemDecision::ExternallyEnforced => { + return Err(MacosFilesystemError::ExternalOwnership.into()); + } + } + let path_context = prepared.path_context(self)?; + if !path_context + .executable_roots() + .iter() + .any(|root| is_within(&program, root)) + { + return Err( + MacosFilesystemError::ProgramRequiresExecutableRoot { path: program }.into(), + ); + } + Ok(()) + } + /// Launches one command in its own Seatbelt process boundary. pub fn spawn<'a>( &self, diff --git a/crates/cageforge-macos/src/error.rs b/crates/cageforge-macos/src/error.rs index 4d39d92e..703db81f 100644 --- a/crates/cageforge-macos/src/error.rs +++ b/crates/cageforge-macos/src/error.rs @@ -179,6 +179,20 @@ pub enum MacosFilesystemError { /// The rejected runtime root. path: PathBuf, }, + /// An absolute command path is readable but is outside the fixed Seatbelt + /// executable baseline and all explicitly mapped runtime roots. + #[error("macOS program requires runtime.executable_roots: {path:?}")] + ProgramRequiresExecutableRoot { + /// The command path that needs executable mapping. + path: PathBuf, + }, + /// An absolute command path is not readable by the effective filesystem + /// policy, so executable mapping cannot make it launchable. + #[error("macOS program requires a filesystem read rule: {path:?}")] + ProgramRequiresRead { + /// The command path without an effective read grant. + path: PathBuf, + }, /// The backend could not inspect a path. #[error("failed to inspect filesystem scope {path:?}: {source}")] Metadata { diff --git a/crates/cageforge-macos/src/lib.rs b/crates/cageforge-macos/src/lib.rs index 1da28a9a..70eb5a8d 100644 --- a/crates/cageforge-macos/src/lib.rs +++ b/crates/cageforge-macos/src/lib.rs @@ -18,6 +18,7 @@ mod error; mod filesystem; mod network; mod process; +mod runtime; mod seatbelt; #[cfg(test)] diff --git a/crates/cageforge-macos/src/runtime.rs b/crates/cageforge-macos/src/runtime.rs new file mode 100644 index 00000000..03cc6249 --- /dev/null +++ b/crates/cageforge-macos/src/runtime.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! macOS runtime paths that belong to the native Seatbelt baseline. + +use std::path::Path; + +use cageforge_path::is_within; + +/// System command roots made executable by the fixed Seatbelt baseline. +pub(crate) const FIXED_EXECUTABLE_ROOTS: &[&str] = + &["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/usr/libexec"]; + +/// Returns whether a program is covered by the fixed native baseline. +pub(crate) fn is_fixed_executable(path: &Path) -> bool { + FIXED_EXECUTABLE_ROOTS + .iter() + .map(Path::new) + .any(|root| is_within(path, root)) +} diff --git a/crates/cageforge-macos/src/seatbelt.rs b/crates/cageforge-macos/src/seatbelt.rs index 759591c2..bce6b87c 100644 --- a/crates/cageforge-macos/src/seatbelt.rs +++ b/crates/cageforge-macos/src/seatbelt.rs @@ -12,6 +12,7 @@ use cageforge_path::is_within; use crate::error::SeatbeltProfileError; use crate::filesystem::MacosFilesystemPlan; use crate::network::{MacosNetworkPlan, MacosUnixSocketPlan}; +use crate::runtime::FIXED_EXECUTABLE_ROOTS; const SEATBELT_BASE_POLICY: &str = r#" (version 1) @@ -62,16 +63,6 @@ const SEATBELT_BASE_POLICY: &str = r#" (subpath "/System/iOSSupport/System/Library/PrivateFrameworks") (subpath "/System/iOSSupport/System/Library/SubFrameworks") (subpath "/usr/lib")) -(allow file-read-data (subpath "/bin")) -(allow file-read-metadata (subpath "/bin")) -(allow file-read-data (subpath "/sbin")) -(allow file-read-metadata (subpath "/sbin")) -(allow file-read-data (subpath "/usr/bin")) -(allow file-read-metadata (subpath "/usr/bin")) -(allow file-read-data (subpath "/usr/sbin")) -(allow file-read-metadata (subpath "/usr/sbin")) -(allow file-read-data (subpath "/usr/libexec")) -(allow file-read-metadata (subpath "/usr/libexec")) (allow file-read* (subpath "/Library/Preferences")) (allow file-read* (subpath "/private/etc")) (allow file-read* (subpath "/etc")) @@ -295,6 +286,7 @@ impl SeatbeltProfile { ) -> Result { let mut builder = ProfileBuilder::new(); builder.push_raw(SEATBELT_BASE_POLICY); + builder.add_fixed_system_executable_roots(); builder.add_filesystem(filesystem)?; if filesystem.unrestricted() { builder.push_raw(SEATBELT_PREFERENCES_POLICY); @@ -346,6 +338,16 @@ impl ProfileBuilder { self.policy.push('\n'); } + fn add_fixed_system_executable_roots(&mut self) { + for root in FIXED_EXECUTABLE_ROOTS { + self.policy + .push_str(&format!("(allow file-read-data (subpath \"{root}\"))\n")); + self.policy.push_str(&format!( + "(allow file-read-metadata (subpath \"{root}\"))\n" + )); + } + } + fn add_filesystem(&mut self, plan: &MacosFilesystemPlan) -> Result<(), SeatbeltProfileError> { for (index, path) in plan.denied_paths().iter().enumerate() { self.add_definition(format!("DENIED_PATH_{index}"), path.clone())?; diff --git a/crates/cageforge-macos/src/seatbelt_tests.rs b/crates/cageforge-macos/src/seatbelt_tests.rs index 56b2458c..afebd089 100644 --- a/crates/cageforge-macos/src/seatbelt_tests.rs +++ b/crates/cageforge-macos/src/seatbelt_tests.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use crate::filesystem::MacosFilesystemPlan; use crate::network::MacosNetworkPlan; - +use crate::runtime::{FIXED_EXECUTABLE_ROOTS, is_fixed_executable}; use crate::seatbelt::{SeatbeltProfile, glob_to_seatbelt_regex, glob_to_seatbelt_regex_exact}; fn filesystem_plan(denied_path: &str) -> MacosFilesystemPlan { @@ -24,6 +24,36 @@ fn filesystem_plan(denied_path: &str) -> MacosFilesystemPlan { } } +#[test] +fn fixed_runtime_baseline_covers_shared_system_executable_roots() { + let profile = SeatbeltProfile::build( + &filesystem_plan("/workspace/private"), + &MacosNetworkPlan::Disabled { + unix: Default::default(), + }, + ) + .expect("profile"); + let policy = profile.policy(); + + for root in FIXED_EXECUTABLE_ROOTS { + assert!( + policy.contains(&format!("(subpath \"{root}\")")), + "fixed macOS baseline does not mention {root}" + ); + } +} + +#[test] +fn fixed_runtime_classifier_does_not_cover_library_roots() { + assert!(!is_fixed_executable(Path::new( + "/System/Library/PrivateTool" + ))); + assert!(!is_fixed_executable(Path::new( + "/Library/Apple/PrivateTool" + ))); + assert!(!is_fixed_executable(Path::new("/usr/bin-not-a-root/tool"))); +} + #[test] fn write_only_carveouts_remain_readable_in_the_profile() { let profile = SeatbeltProfile::build( diff --git a/crates/cageforge-macos/tests/macos_backend.rs b/crates/cageforge-macos/tests/macos_backend.rs index b9214134..ba5ec8c5 100644 --- a/crates/cageforge-macos/tests/macos_backend.rs +++ b/crates/cageforge-macos/tests/macos_backend.rs @@ -351,6 +351,24 @@ fn backend() -> MacosBackend { MacosBackend::new(MacosBackendConfig::new()).expect("macOS Seatbelt is available") } +#[test] +fn absolute_custom_program_is_rejected_by_backend_without_executable_mapping() { + let workspace = TempDir::new().expect("workspace"); + let policy = SandboxPolicy::full_access(); + let command = CommandSpec::new("/tmp/cageforge-custom-program").expect("program"); + let (command, effective, context) = request_for(workspace.path(), &policy, command); + let error = backend() + .prepare(BackendRequest::new(&command, &effective), &context) + .expect_err("custom absolute program must require an executable root"); + + assert!(matches!( + error, + MacosBackendError::Filesystem(MacosFilesystemError::ProgramRequiresExecutableRoot { + path + }) if path == Path::new("/tmp/cageforge-custom-program") + )); +} + fn restricted_policy(workspace: &Path) -> SandboxPolicy { SandboxPolicy::new( FilesystemPolicy::restricted([ diff --git a/specs/0024-macos-executable-runtime-roots.md b/specs/0024-macos-executable-runtime-roots.md index 98ba8ca7..01ef433a 100644 --- a/specs/0024-macos-executable-runtime-roots.md +++ b/specs/0024-macos-executable-runtime-roots.md @@ -25,11 +25,19 @@ rules = [ executable_roots = ["/opt/example-runtime"] ``` -`runtime.executable_roots` contains existing absolute directories. The config -layer rejects empty, NUL-containing, relative, parent-traversing, and duplicate -paths. The macOS backend additionally rejects missing roots, symlinked -ancestors, non-directories, and roots not covered by effective read or write -filesystem access. The root is canonicalized before native policy generation. +`runtime.executable_roots` contains absolute directories. The config layer +validates only the portable TOML shape: empty, NUL-containing, relative, +parent-traversing, and duplicate paths are rejected. It selects the overlay +but does not decide which paths the macOS Seatbelt baseline treats as native +system runtime paths. That decision belongs to the macOS backend. + +The macOS backend additionally rejects missing roots, symlinked ancestors, +non-directories, and roots not covered by effective read or write filesystem +access. The root is canonicalized before native policy generation. An absolute +command outside the backend's fixed executable baseline must be readable and +must be contained by an explicit executable root; otherwise preparation +returns a typed `ProgramRequiresRead` or `ProgramRequiresExecutableRoot` +failure before spawn. Platform overlays validate path syntax for their declared target platform, so one portable TOML document may contain all three operating-system overlays. @@ -66,9 +74,12 @@ directory contents, sibling paths, or write access. The metadata ancestors are derived from the validated root, not from a global `/Users`, `/Library`, or `/Applications` allowlist. -System runtime paths retain the fixed Seatbelt baseline. This capability is for -non-system runtime roots and must not be implemented by granting executable -mapping to all readable paths or the whole filesystem. +System runtime paths retain the fixed Seatbelt baseline. The baseline list is +owned by `cageforge-macos` and is used both by command validation and Seatbelt +policy generation. It is not duplicated in `cageforge-config` or +`cageforge-path`. This capability is for non-system runtime roots and must not +be implemented by granting executable mapping to all readable paths or the +whole filesystem. ## Permission and binding contract diff --git a/specs/0026-structured-configuration-diagnostics.md b/specs/0026-structured-configuration-diagnostics.md new file mode 100644 index 00000000..e1031267 --- /dev/null +++ b/specs/0026-structured-configuration-diagnostics.md @@ -0,0 +1,56 @@ +# Specification 0026: Structured Configuration Diagnostics + +Status: active implementation contract + +## Purpose + +Cageforge configuration failures must be useful to both a human invoking the +CLI and a host language binding. A display string is not the API contract. +Rust exposes the typed error; the CLI renders one concise diagnostic; Python +and Java preserve the same category and source metadata in their exception +types. + +## Configuration boundary + +`cageforge-config` parses TOML, selects the requested platform overlay, merges +profiles, and validates portable configuration invariants. It does not decide +which paths a native backend treats as system runtime paths or how a native +policy is lowered. Backend-specific checks belong to the selected backend and +must return that backend's typed error before spawn. + +Each `ConfigError` may carry `ConfigErrorContext` containing: + +- the configuration path, when the source came from a file; +- the selected `PlatformId`; +- the source line, column, byte offset, and span length; +- the profile and logical field identified by the error. + +`ConfigDiagnostic` exposes a stable code, severity, message, profile, field, +platform, path, and source location. `to_json()` is for machines; the CLI +uses `render_human()` and must not require callers to parse a display string. + +## Native boundary + +The selected backend performs native capability checks after composition and +before process creation. For macOS, Seatbelt owns the fixed executable +baseline and checks absolute custom programs against effective read access and +explicit `runtime.executable_roots`. It returns typed errors such as +`ProgramRequiresRead` and `ProgramRequiresExecutableRoot`; config does not +duplicate the Seatbelt baseline. + +## Foreign bindings + +Python raises `CageforgeConfigurationError` with stable attributes: +`code`, `config_path`, `profile`, `platform`, `field`, `line`, and `column`. +Java raises `CageforgeConfigurationException` with the equivalent properties. +Bindings must preserve the error category and nested native cause, never let a +Rust panic cross FFI, and never use raw stderr or a sentinel success value as +the public error protocol. + +## Verification + +Rust tests cover typed variants, source context, platform overlays, and native +preflight failures. CLI tests use snapshots for the human rendering. Python +and Java tests verify the stable exception category and metadata. Native jobs +remain responsible for executing the backend-specific checks on their actual +operating system. From 774980654303fee8c74d601452d5e46fdf4747e6 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Sun, 20 Sep 2026 18:50:38 +0500 Subject: [PATCH 02/13] test: declare native executable roots in platform fixtures Co-authored-by: codex --- crates/cageforge-config/tests/platforms.rs | 6 ------ crates/cageforge-macos/tests/macos_backend.rs | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/cageforge-config/tests/platforms.rs b/crates/cageforge-config/tests/platforms.rs index c27b408f..20115990 100644 --- a/crates/cageforge-config/tests/platforms.rs +++ b/crates/cageforge-config/tests/platforms.rs @@ -195,9 +195,6 @@ default_profile = "tool" [profiles.tool.platforms.macos.runtime] executable_roots = ["/opt/example-runtime"] - -[profiles.tool.platforms.macos.filesystem] -rules = [{ target = "absolute", path = "/opt/example-runtime", access = "read" }] "#, ) .expect("runtime-root configuration"); @@ -227,9 +224,6 @@ default_profile = "tool" [profiles.tool.platforms.macos.runtime] executable_roots = ["/opt/example-runtime"] -[profiles.tool.platforms.macos.filesystem] -rules = [{ target = "absolute", path = "/opt/example-runtime", access = "read" }] - [profiles.tool.platforms.windows.runtime] executable_roots = ["C:/Program Files/example-runtime"] "#, diff --git a/crates/cageforge-macos/tests/macos_backend.rs b/crates/cageforge-macos/tests/macos_backend.rs index ba5ec8c5..0cc5efde 100644 --- a/crates/cageforge-macos/tests/macos_backend.rs +++ b/crates/cageforge-macos/tests/macos_backend.rs @@ -72,6 +72,11 @@ impl Drop for LaunchdTestJob { } fn context(workspace: &Path) -> PathResolutionContext { + let test_executable_root = std::env::current_exe() + .expect("test executable") + .parent() + .expect("test executable directory") + .to_path_buf(); PathResolutionContext::new() .with_root(PathBuf::from("/")) .expect("root") @@ -91,6 +96,8 @@ fn context(workspace: &Path) -> PathResolutionContext { .expect("slash tmp") .with_current_directory(workspace.to_path_buf()) .expect("cwd") + .with_executable_root(test_executable_root) + .expect("test executable root") } #[test] From 5b6de07417be75e14cb49e86268b9d50978fce0c Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Sun, 20 Sep 2026 18:58:21 +0500 Subject: [PATCH 03/13] test(macos): scope executable roots to launched fixtures Co-authored-by: codex --- crates/cageforge-macos/tests/macos_backend.rs | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/cageforge-macos/tests/macos_backend.rs b/crates/cageforge-macos/tests/macos_backend.rs index 0cc5efde..7f1bd499 100644 --- a/crates/cageforge-macos/tests/macos_backend.rs +++ b/crates/cageforge-macos/tests/macos_backend.rs @@ -72,11 +72,6 @@ impl Drop for LaunchdTestJob { } fn context(workspace: &Path) -> PathResolutionContext { - let test_executable_root = std::env::current_exe() - .expect("test executable") - .parent() - .expect("test executable directory") - .to_path_buf(); PathResolutionContext::new() .with_root(PathBuf::from("/")) .expect("root") @@ -96,8 +91,22 @@ fn context(workspace: &Path) -> PathResolutionContext { .expect("slash tmp") .with_current_directory(workspace.to_path_buf()) .expect("cwd") - .with_executable_root(test_executable_root) - .expect("test executable root") +} + +fn context_for_command(workspace: &Path, program: &Path) -> PathResolutionContext { + let context = context(workspace); + let test_executable = std::env::current_exe().expect("test executable"); + if program == test_executable { + return context + .with_executable_root( + test_executable + .parent() + .expect("test executable directory") + .to_path_buf(), + ) + .expect("test executable root"); + } + context } #[test] @@ -424,7 +433,8 @@ fn request_for( .with_stderr(StdioMode::Pipe), ) .with_environment(environment); - (command, effective, context(workspace)) + let context = context_for_command(workspace, Path::new(command.command().program())); + (command, effective, context) } fn network_request( @@ -458,7 +468,9 @@ fn network_request( .with_stderr(StdioMode::Pipe), ) .with_environment(environment); - (command, effective, context(workspace)) + let executable = PathBuf::from(std::env::current_exe().expect("test executable")); + let context = context_for_command(workspace, &executable); + (command, effective, context) } fn start_http_server() -> (SocketAddr, thread::JoinHandle>) { @@ -590,7 +602,9 @@ fn unix_network_request( .with_stderr(StdioMode::Pipe), ) .with_environment(environment); - (command, effective, context(workspace)) + let executable = PathBuf::from(std::env::current_exe().expect("test executable")); + let context = context_for_command(workspace, &executable); + (command, effective, context) } fn proxy_endpoint(value: &str) -> SocketAddr { @@ -2010,7 +2024,7 @@ fn successful_wait_terminates_descendants_that_change_group_or_session() { let ceiling = PolicyCeiling::new(SandboxPolicy::full_access(), environment.clone()); let effective = compose(CompositionRequest::new(&policy, &environment, &ceiling)) .expect("compose policy"); - let context = context(&root) + let context = context_for_command(&root, &executable) .with_minimal_path(executable.clone()) .expect("fixture runtime path"); let command = CommandRequest::new( From 2a706fe22984c26e031843b2e114b8d3bec6682b Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Sun, 20 Sep 2026 19:00:21 +0500 Subject: [PATCH 04/13] fix(macos): satisfy native fixture lint Co-authored-by: codex --- crates/cageforge-macos/tests/macos_backend.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cageforge-macos/tests/macos_backend.rs b/crates/cageforge-macos/tests/macos_backend.rs index 7f1bd499..e898a6fb 100644 --- a/crates/cageforge-macos/tests/macos_backend.rs +++ b/crates/cageforge-macos/tests/macos_backend.rs @@ -468,7 +468,7 @@ fn network_request( .with_stderr(StdioMode::Pipe), ) .with_environment(environment); - let executable = PathBuf::from(std::env::current_exe().expect("test executable")); + let executable = std::env::current_exe().expect("test executable"); let context = context_for_command(workspace, &executable); (command, effective, context) } @@ -602,7 +602,7 @@ fn unix_network_request( .with_stderr(StdioMode::Pipe), ) .with_environment(environment); - let executable = PathBuf::from(std::env::current_exe().expect("test executable")); + let executable = std::env::current_exe().expect("test executable"); let context = context_for_command(workspace, &executable); (command, effective, context) } From 0f14fb19a13893e5900e17e04b008272afd9ecfd Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Sun, 20 Sep 2026 19:03:55 +0500 Subject: [PATCH 05/13] test(macos): grant fixture runtime read access Co-authored-by: codex --- crates/cageforge-macos/tests/macos_backend.rs | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/cageforge-macos/tests/macos_backend.rs b/crates/cageforge-macos/tests/macos_backend.rs index e898a6fb..67d48bcc 100644 --- a/crates/cageforge-macos/tests/macos_backend.rs +++ b/crates/cageforge-macos/tests/macos_backend.rs @@ -109,6 +109,28 @@ fn context_for_command(workspace: &Path, program: &Path) -> PathResolutionContex context } +fn policy_for_command(policy: &SandboxPolicy, program: &Path) -> SandboxPolicy { + let test_executable = std::env::current_exe().expect("test executable"); + if program != test_executable + || policy.filesystem().mode() != cageforge_policy::FilesystemMode::Restricted + { + return policy.clone(); + } + let root = test_executable + .parent() + .expect("test executable directory") + .to_path_buf(); + let filesystem = policy + .filesystem() + .clone() + .with_rule(FilesystemRule::new( + PathSelector::absolute(root).expect("test executable root selector"), + AccessMode::Read, + )) + .expect("restricted test policy"); + SandboxPolicy::new(filesystem, policy.network().clone()) +} + #[test] fn runnable_macos_profile_launches_through_the_native_backend_api() { let config_path = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -421,9 +443,10 @@ fn request_for( PathResolutionContext, ) { let environment = EnvironmentSpec::inherit_core(); + let policy = policy_for_command(policy, Path::new(command.program())); let ceiling = PolicyCeiling::new(SandboxPolicy::full_access(), environment.clone()); let effective = - compose(CompositionRequest::new(policy, &environment, &ceiling)).expect("compose policy"); + compose(CompositionRequest::new(&policy, &environment, &ceiling)).expect("compose policy"); let command = CommandRequest::new(command) .with_working_directory(workspace.to_path_buf()) .expect("working directory") @@ -456,9 +479,13 @@ fn network_request( .expect("test executable command") .with_args(["--exact", "network_client_fixture", "--nocapture"]) .expect("fixture arguments"); + let policy = policy_for_command( + policy, + std::env::current_exe().expect("test executable").as_path(), + ); let ceiling = PolicyCeiling::new(SandboxPolicy::full_access(), environment.clone()); let effective = - compose(CompositionRequest::new(policy, &environment, &ceiling)).expect("compose policy"); + compose(CompositionRequest::new(&policy, &environment, &ceiling)).expect("compose policy"); let command = CommandRequest::new(command) .with_working_directory(workspace.to_path_buf()) .expect("working directory") @@ -590,9 +617,13 @@ fn unix_network_request( .expect("test executable command") .with_args(["--exact", "network_client_fixture", "--nocapture"]) .expect("fixture arguments"); + let policy = policy_for_command( + policy, + std::env::current_exe().expect("test executable").as_path(), + ); let ceiling = PolicyCeiling::new(SandboxPolicy::full_access(), environment.clone()); let effective = - compose(CompositionRequest::new(policy, &environment, &ceiling)).expect("compose policy"); + compose(CompositionRequest::new(&policy, &environment, &ceiling)).expect("compose policy"); let command = CommandRequest::new(command) .with_working_directory(workspace.to_path_buf()) .expect("working directory") @@ -2020,7 +2051,7 @@ fn successful_wait_terminates_descendants_that_change_group_or_session() { .expect("fixture mode") .with_var(GROUP_CHANGE_ROOT, root.as_os_str()) .expect("fixture root"); - let policy = writable_policy(&root); + let policy = policy_for_command(&writable_policy(&root), &executable); let ceiling = PolicyCeiling::new(SandboxPolicy::full_access(), environment.clone()); let effective = compose(CompositionRequest::new(&policy, &environment, &ceiling)) .expect("compose policy"); From b733c8b7c04a445e444996819b312de68f0e0820 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Sun, 20 Sep 2026 20:05:13 +0500 Subject: [PATCH 06/13] fix: preserve source context in native diagnostics Co-authored-by: codex --- .../src/main/kotlin/ai/cageforge/Cageforge.kt | 1 + .../kotlin/ai/cageforge/CageforgeException.kt | 15 +- .../kotlin/ai/cageforge/RuntimeContextTest.kt | 24 ++ crates/bindings/cageforge-java/src/error.rs | 78 +++++- crates/bindings/cageforge-java/src/lib.rs | 23 +- .../python/cageforge/_cageforge.pyi | 10 + .../cageforge-python/src/bin/stub_gen.rs | 6 +- crates/bindings/cageforge-python/src/lib.rs | 68 ++++- crates/cageforge-cli/src/error.rs | 12 + crates/cageforge-cli/src/execution.rs | 100 ++++++- crates/cageforge-cli/tests/cli.rs | 59 +++- .../cli__native_generic_failure.snap | 9 + ...tive_program_requires_executable_root.snap | 10 + .../cli__native_program_requires_read.snap | 10 + crates/cageforge-config/src/diagnostics.rs | 44 ++- crates/cageforge-config/src/lib.rs | 2 +- crates/cageforge-config/src/resolve.rs | 261 +++++++++++++++--- crates/cageforge-config/tests/config.rs | 32 ++- ...26-structured-configuration-diagnostics.md | 20 +- 19 files changed, 712 insertions(+), 72 deletions(-) create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_generic_failure.snap create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_program_requires_executable_root.snap create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_program_requires_read.snap diff --git a/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/Cageforge.kt b/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/Cageforge.kt index f943b368..0a4818ce 100644 --- a/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/Cageforge.kt +++ b/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/Cageforge.kt @@ -223,6 +223,7 @@ class Cageforge private constructor( error.field, error.line, error.column, + error.command, ) } } diff --git a/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/CageforgeException.kt b/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/CageforgeException.kt index aa2d67d1..e5c7c54b 100644 --- a/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/CageforgeException.kt +++ b/crates/bindings/cageforge-java/jvm/src/main/kotlin/ai/cageforge/CageforgeException.kt @@ -23,6 +23,7 @@ class CageforgeConfigurationException( val field: String?, val line: Int?, val column: Int?, + val command: String? = null, ) : CageforgeException(message) { /** Creates a category-only configuration failure for host-side validation. */ constructor(message: String) : this(message, "configuration_error", null, null, null, null, null, null) @@ -32,7 +33,19 @@ class CageforgeConfigurationException( class CageforgeInitializationException(message: String) : CageforgeException(message) /** Raised when a command cannot be launched through the native sandbox. */ -class CageforgeLaunchException(message: String) : CageforgeException(message) +class CageforgeLaunchException( + message: String, + val code: String, + val configPath: String?, + val profile: String?, + val platform: String?, + val field: String?, + val line: Int?, + val column: Int?, + val command: String?, +) : CageforgeException(message) { + constructor(message: String) : this(message, "native_launch_error", null, null, null, null, null, null, null) +} /** Raised when a trusted preflight grant is missing, invalid, or insufficient. */ open class CageforgePermissionException(message: String) : CageforgeException(message) diff --git a/crates/bindings/cageforge-java/jvm/src/test/kotlin/ai/cageforge/RuntimeContextTest.kt b/crates/bindings/cageforge-java/jvm/src/test/kotlin/ai/cageforge/RuntimeContextTest.kt index 73ab7c36..87833fe5 100644 --- a/crates/bindings/cageforge-java/jvm/src/test/kotlin/ai/cageforge/RuntimeContextTest.kt +++ b/crates/bindings/cageforge-java/jvm/src/test/kotlin/ai/cageforge/RuntimeContextTest.kt @@ -44,6 +44,30 @@ class RuntimeContextTest { assertEquals(3, error.column) } + @Test + fun launchExceptionKeepsNativeSourceFields() { + val error = + CageforgeLaunchException( + "native launch failed", + "macos_program_requires_executable_root", + "/tmp/tool.toml", + "tool", + "macos", + "runtime.executable_roots", + 8, + 1, + "/opt/tool/bin/runner --worker one", + ) + assertEquals("macos_program_requires_executable_root", error.code) + assertEquals("/tmp/tool.toml", error.configPath) + assertEquals("tool", error.profile) + assertEquals("macos", error.platform) + assertEquals("runtime.executable_roots", error.field) + assertEquals(8, error.line) + assertEquals(1, error.column) + assertEquals("/opt/tool/bin/runner --worker one", error.command) + } + @Test fun windowsSetupIsTypedAndNonDestructiveOnNonWindows() { if (WindowsSetup.isSupported()) return diff --git a/crates/bindings/cageforge-java/src/error.rs b/crates/bindings/cageforge-java/src/error.rs index 5ae8840b..6f65f6dc 100644 --- a/crates/bindings/cageforge-java/src/error.rs +++ b/crates/bindings/cageforge-java/src/error.rs @@ -97,6 +97,7 @@ pub(crate) struct BindingDiagnostic { pub(crate) profile: Option, pub(crate) platform: Option, pub(crate) field: Option, + pub(crate) command: Option, pub(crate) line: Option, pub(crate) column: Option, } @@ -115,10 +116,59 @@ impl BindingDiagnostic { .platform() .map(|platform| platform.as_str().to_owned()), field: diagnostic.field().map(str::to_owned), + command: diagnostic.command().map(str::to_owned), line: location.and_then(|value| i32::try_from(value.line).ok()), column: location.and_then(|value| i32::try_from(value.column).ok()), } } + + pub(crate) fn from_runtime( + source: &cageforge::ProfileSourceContext, + command: &cageforge::CommandRequest, + kind: &'static str, + message: impl Into, + ) -> (Self, String) { + let source = source.clone().with_command(display_command(command)); + let message = message.into(); + let diagnostic = cageforge::ConfigDiagnostic::for_runtime_failure( + &source, + kind, + message, + Some("command.program"), + ); + let location = diagnostic.location(); + ( + Self { + code: diagnostic.code().to_owned(), + config_path: diagnostic + .config_path() + .map(|path| path.display().to_string()), + profile: diagnostic.profile().map(str::to_owned), + platform: diagnostic + .platform() + .map(|platform| platform.as_str().to_owned()), + field: diagnostic.field().map(str::to_owned), + command: diagnostic.command().map(str::to_owned), + line: location.and_then(|value| i32::try_from(value.line).ok()), + column: location.and_then(|value| i32::try_from(value.column).ok()), + }, + diagnostic.render_human(), + ) + } +} + +fn display_command(command: &cageforge::CommandRequest) -> String { + std::iter::once(command.command().program()) + .chain( + command + .command() + .args() + .iter() + .map(std::ffi::OsString::as_os_str), + ) + .map(|part| part.to_string_lossy().into_owned()) + .collect::>() + .join(" ") } #[derive(Debug)] @@ -146,6 +196,22 @@ impl BindingError { } } + pub(crate) fn runtime( + kind: BindingErrorKind, + source: &cageforge::ProfileSourceContext, + command: &cageforge::CommandRequest, + code: &'static str, + message: impl Into, + ) -> Self { + let (diagnostic, rendered) = + BindingDiagnostic::from_runtime(source, command, code, message); + Self { + message: rendered, + kind, + diagnostic: Some(Box::new(diagnostic)), + } + } + pub(crate) fn with_default_kind(mut self, kind: BindingErrorKind) -> Self { if matches!(self.kind, BindingErrorKind::Internal) { self.kind = kind; @@ -184,7 +250,7 @@ impl From for BindingError { } } -fn throw_configuration_exception( +fn throw_diagnostic_exception( env: &mut Env<'_>, class: &JClass<'_>, message: &str, @@ -212,6 +278,11 @@ fn throw_configuration_exception( .as_deref() .map(|value| env.new_string(value)) .transpose()?; + let command = diagnostic + .command + .as_deref() + .map(|value| env.new_string(value)) + .transpose()?; let line = match diagnostic.line { Some(value) => env.new_object( jni::jni_str!("java/lang/Integer"), @@ -230,7 +301,7 @@ fn throw_configuration_exception( }; let object = env.new_object( class, - jni::jni_sig!("(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V"), + jni::jni_sig!("(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;)V"), &[ JValue::Object(message.as_ref()), JValue::Object(code.as_ref()), @@ -240,6 +311,7 @@ fn throw_configuration_exception( JValue::Object(field.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())), JValue::Object(&line), JValue::Object(&column), + JValue::Object(command.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())), ], )?; let throwable = unsafe { JThrowable::from_raw(env, object.into_raw()) }; @@ -259,7 +331,7 @@ impl ErrorPolicy for ThrowCageforgeException { if !env.exception_check() { let class = error.kind.class(env)?; if let Some(diagnostic) = error.diagnostic.as_ref() { - if throw_configuration_exception(env, &class, &error.message, diagnostic).is_err() { + if throw_diagnostic_exception(env, &class, &error.message, diagnostic).is_err() { let message = jni::strings::JNIString::new(error.to_string()); let _ = env.throw_new(class, message); } diff --git a/crates/bindings/cageforge-java/src/lib.rs b/crates/bindings/cageforge-java/src/lib.rs index 9ae29f63..add3ece7 100644 --- a/crates/bindings/cageforge-java/src/lib.rs +++ b/crates/bindings/cageforge-java/src/lib.rs @@ -30,6 +30,7 @@ struct RuntimeState { preflight_required: bool, approved_program: Option, preflight_plan: Option, + source: cageforge::ProfileSourceContext, } struct ChildState { @@ -252,6 +253,7 @@ fn runtime_from_toml( preflight_required: false, approved_program: None, preflight_plan: None, + source: profile.source_context().clone(), }; return Ok(Box::into_raw(Box::new(state)) as jlong); } @@ -302,6 +304,7 @@ fn runtime_from_toml( preflight_required: requires_grant, approved_program, preflight_plan: Some(plan), + source: profile.source_context().clone(), }; Ok(Box::into_raw(Box::new(state)) as jlong) } @@ -1306,7 +1309,15 @@ pub extern "system" fn Java_ai_cageforge_NativeBridge_nativeLaunch<'caller>( let mut child = runtime .backend .launch(backend_request, &runtime.context) - .map_err(|error| error.to_string())?; + .map_err(|error| { + BindingError::runtime( + BindingErrorKind::Launch, + &runtime.source, + &request, + "native_launch_error", + error.to_string(), + ) + })?; let stdin = child.take_stdin(); let stdout = child.take_stdout(); let stderr = child.take_stderr(); @@ -1354,7 +1365,15 @@ pub extern "system" fn Java_ai_cageforge_NativeBridge_nativeLaunchEscalated<'cal let mut child = runtime .backend .launch(backend_request, &context) - .map_err(|error| error.to_string())?; + .map_err(|error| { + BindingError::runtime( + BindingErrorKind::Escalation, + &runtime.source, + &request, + "native_escalation_error", + error.to_string(), + ) + })?; Ok(Box::into_raw(Box::new(ChildState { stdin: Mutex::new(child.take_stdin()), stdout: Mutex::new(child.take_stdout()), diff --git a/crates/bindings/cageforge-python/python/cageforge/_cageforge.pyi b/crates/bindings/cageforge-python/python/cageforge/_cageforge.pyi index 9b102243..69ef4683 100644 --- a/crates/bindings/cageforge-python/python/cageforge/_cageforge.pyi +++ b/crates/bindings/cageforge-python/python/cageforge/_cageforge.pyi @@ -111,6 +111,7 @@ class CageforgeConfigurationError(CageforgeError): profile: typing.Optional[builtins.str] platform: typing.Optional[builtins.str] field: typing.Optional[builtins.str] + command: typing.Optional[builtins.str] line: typing.Optional[builtins.int] column: typing.Optional[builtins.int] @@ -160,6 +161,15 @@ class CageforgeLaunchError(CageforgeError): r""" The native sandbox process could not be launched. """ + code: builtins.str + config_path: typing.Optional[builtins.str] + profile: typing.Optional[builtins.str] + platform: typing.Optional[builtins.str] + field: typing.Optional[builtins.str] + command: typing.Optional[builtins.str] + line: typing.Optional[builtins.int] + column: typing.Optional[builtins.int] + ... class CageforgeListingSnapshotExpiredError(CageforgeStoreError): diff --git a/crates/bindings/cageforge-python/src/bin/stub_gen.rs b/crates/bindings/cageforge-python/src/bin/stub_gen.rs index 98efe7f4..48b0bb95 100644 --- a/crates/bindings/cageforge-python/src/bin/stub_gen.rs +++ b/crates/bindings/cageforge-python/src/bin/stub_gen.rs @@ -23,7 +23,11 @@ fn main() -> pyo3_stub_gen::Result<()> { .replace("os.PathLike", "os.PathLike[str]"); let stub = stub.replace( " ...\n\nclass CageforgeError(builtins.Exception):", - " code: builtins.str\n config_path: typing.Optional[builtins.str]\n profile: typing.Optional[builtins.str]\n platform: typing.Optional[builtins.str]\n field: typing.Optional[builtins.str]\n line: typing.Optional[builtins.int]\n column: typing.Optional[builtins.int]\n\nclass CageforgeError(builtins.Exception):", + " code: builtins.str\n config_path: typing.Optional[builtins.str]\n profile: typing.Optional[builtins.str]\n platform: typing.Optional[builtins.str]\n field: typing.Optional[builtins.str]\n command: typing.Optional[builtins.str]\n line: typing.Optional[builtins.int]\n column: typing.Optional[builtins.int]\n\nclass CageforgeError(builtins.Exception):", + ); + let stub = stub.replace( + "class CageforgeLaunchError(CageforgeError):\n r\"\"\"\n The native sandbox process could not be launched.\n \"\"\"\n ...", + "class CageforgeLaunchError(CageforgeError):\n r\"\"\"\n The native sandbox process could not be launched.\n \"\"\"\n code: builtins.str\n config_path: typing.Optional[builtins.str]\n profile: typing.Optional[builtins.str]\n platform: typing.Optional[builtins.str]\n field: typing.Optional[builtins.str]\n command: typing.Optional[builtins.str]\n line: typing.Optional[builtins.int]\n column: typing.Optional[builtins.int]\n\n ...", ); let stub = format!("{}\n", stub.trim_end()); std::fs::write(target, stub)?; diff --git a/crates/bindings/cageforge-python/src/lib.rs b/crates/bindings/cageforge-python/src/lib.rs index 20b470b3..3439a244 100644 --- a/crates/bindings/cageforge-python/src/lib.rs +++ b/crates/bindings/cageforge-python/src/lib.rs @@ -160,6 +160,7 @@ struct RuntimeState { preflight_required: bool, approved_program: Option, preflight_plan: Option, + source: cageforge::ProfileSourceContext, } struct LifecycleState { @@ -367,6 +368,7 @@ fn configuration_diagnostic_error(error: cageforge::ConfigError) -> PyErr { diagnostic.platform().map(|platform| platform.as_str()), )?; value.setattr("field", diagnostic.field())?; + value.setattr("command", diagnostic.command())?; value.setattr("line", diagnostic.location().map(|location| location.line))?; value.setattr( "column", @@ -389,6 +391,50 @@ fn launch_error(error: impl ToString) -> PyErr { CageforgeLaunchError::new_err(error.to_string()) } +fn launch_diagnostic_error( + source: &cageforge::ProfileSourceContext, + command: &cageforge::CommandRequest, + error: impl ToString, +) -> PyErr { + let source = source.clone().with_command(display_command(command)); + let diagnostic = cageforge::ConfigDiagnostic::for_runtime_failure( + &source, + "native_launch_error", + error.to_string(), + Some("command.program"), + ); + let exception = CageforgeLaunchError::new_err(diagnostic.render_human()); + Python::attach(|py| { + let value = exception.value(py); + let set_result = (|| -> PyResult<()> { + value.setattr("code", diagnostic.code())?; + value.setattr( + "config_path", + diagnostic + .config_path() + .map(|path| path.display().to_string()), + )?; + value.setattr("profile", diagnostic.profile())?; + value.setattr( + "platform", + diagnostic.platform().map(|platform| platform.as_str()), + )?; + value.setattr("field", diagnostic.field())?; + value.setattr("command", diagnostic.command())?; + value.setattr("line", diagnostic.location().map(|location| location.line))?; + value.setattr( + "column", + diagnostic.location().map(|location| location.column), + )?; + Ok(()) + })(); + if let Err(attribute_error) = set_result { + return attribute_error; + } + exception + }) +} + fn permission_error(error: impl ToString) -> PyErr { CageforgePermissionError::new_err(error.to_string()) } @@ -426,6 +472,20 @@ fn process_error(error: impl ToString) -> PyErr { CageforgeProcessError::new_err(error.to_string()) } +fn display_command(command: &cageforge::CommandRequest) -> String { + std::iter::once(command.command().program()) + .chain( + command + .command() + .args() + .iter() + .map(std::ffi::OsString::as_os_str), + ) + .map(|part| part.to_string_lossy().into_owned()) + .collect::>() + .join(" ") +} + fn stream_error(error: impl ToString) -> PyErr { CageforgeStreamError::new_err(error.to_string()) } @@ -1074,6 +1134,7 @@ impl Cageforge { preflight_required: false, approved_program: None, preflight_plan: None, + source: profile.source_context().clone(), }); } let identity = preflight_identity(request.as_ref(), &toml) @@ -1127,6 +1188,7 @@ impl Cageforge { preflight_required: requires_grant, approved_program, preflight_plan: Some(plan), + source: profile.source_context().clone(), }) })?; Ok(Self { @@ -1202,10 +1264,11 @@ impl Cageforge { } } let backend_request = cageforge::BackendRequest::new(&request, &runtime.effective); + let source = runtime.source.clone(); let mut child = runtime .backend .launch(backend_request, &runtime.context) - .map_err(launch_error)?; + .map_err(|error| launch_diagnostic_error(&source, &request, error))?; Ok::<_, PyErr>(ChildState { stdin: Mutex::new(child.take_stdin()), stdout: Mutex::new(child.take_stdout()), @@ -1301,10 +1364,11 @@ impl Cageforge { .cloned() .unwrap_or_else(|| runtime.context.clone()); let backend_request = cageforge::BackendRequest::new(&request, authorized.effective()); + let source = runtime.source.clone(); let mut child = runtime .backend .launch(backend_request, &context) - .map_err(launch_error)?; + .map_err(|error| launch_diagnostic_error(&source, &request, error))?; Ok::<_, PyErr>(ChildState { stdin: Mutex::new(child.take_stdin()), stdout: Mutex::new(child.take_stdout()), diff --git a/crates/cageforge-cli/src/error.rs b/crates/cageforge-cli/src/error.rs index acf326fd..d359e06c 100644 --- a/crates/cageforge-cli/src/error.rs +++ b/crates/cageforge-cli/src/error.rs @@ -37,6 +37,16 @@ pub enum CliError { #[cfg(feature = "config")] #[error("configuration: {0}")] Config(#[from] cageforge::ConfigError), + /// A native failure enriched with the selected profile's source context. + #[cfg(feature = "config")] + #[error("{diagnostic}")] + NativeDiagnostic { + /// Stable source-aware presentation metadata. + diagnostic: Box, + /// The original typed CLI/native failure. + #[source] + source: Box, + }, /// The portable command model rejected a value. #[error("command: {0}")] Command(#[from] cageforge::CommandError), @@ -121,6 +131,8 @@ impl CliError { "configuration error:\n{}", error.diagnostic().render_human() ), + #[cfg(feature = "config")] + Self::NativeDiagnostic { diagnostic, .. } => diagnostic.render_human(), _ => self.to_string(), } } diff --git a/crates/cageforge-cli/src/execution.rs b/crates/cageforge-cli/src/execution.rs index e230a548..2ddb0a58 100644 --- a/crates/cageforge-cli/src/execution.rs +++ b/crates/cageforge-cli/src/execution.rs @@ -41,6 +41,7 @@ struct Invocation { effective: cageforge::EffectiveSandbox, context: cageforge::PathResolutionContext, gateway: cageforge::GatewayConfig, + source: cageforge::ProfileSourceContext, } /// Executes a parsed CLI request and returns the process exit code. @@ -146,6 +147,10 @@ fn execute_run(args: RunArgs) -> Result { None => config.resolve_default_for_platform(platform)?, }; let command = command_from_args(&profile, args.command)?; + let source = profile + .source_context() + .clone() + .with_command(display_command(&command)); let current_directory = std::env::current_dir()?; let workspace_roots = resolve_workspace_roots(¤t_directory, profile.workspace_roots())?; let context = runtime_context( @@ -236,6 +241,7 @@ fn execute_run(args: RunArgs) -> Result { effective, context, gateway: profile.network_gateway().clone(), + source, }; #[cfg(target_os = "windows")] warn_if_windows_setup_is_unavailable(); @@ -472,6 +478,21 @@ fn command_from_args( Ok(request) } +#[cfg(feature = "config")] +fn display_command(command: &cageforge::CommandRequest) -> String { + std::iter::once(command.command().program()) + .chain( + command + .command() + .args() + .iter() + .map(std::ffi::OsString::as_os_str), + ) + .map(|part| part.to_string_lossy().into_owned()) + .collect::>() + .join(" ") +} + #[cfg(feature = "config")] fn resolve_workspace_roots( current_directory: &Path, @@ -574,6 +595,15 @@ fn platform_minimal_root(current_directory: &Path) -> Result any(target_os = "linux", target_os = "windows", target_os = "macos",) ))] fn execute_native(invocation: Invocation) -> Result { + let source = invocation.source.clone(); + execute_native_inner(invocation).map_err(|error| enrich_native_error(source, error)) +} + +#[cfg(all( + feature = "config", + any(target_os = "linux", target_os = "windows", target_os = "macos",) +))] +fn execute_native_inner(invocation: Invocation) -> Result { let config = cageforge::NativeSandboxConfig::new().with_network_gateway(invocation.gateway); #[cfg(target_os = "linux")] let config = config.with_hardening_helper_path(std::env::current_exe()?); @@ -587,6 +617,73 @@ fn execute_native(invocation: Invocation) -> Result { Ok(child.wait()?.code().unwrap_or(1) as u8) } +#[cfg(all( + feature = "config", + any(target_os = "linux", target_os = "windows", target_os = "macos",) +))] +fn enrich_native_error(source: cageforge::ProfileSourceContext, error: CliError) -> CliError { + let (code, field) = native_diagnostic_metadata(&error); + let diagnostic = + cageforge::ConfigDiagnostic::for_runtime_failure(&source, code, error.to_string(), field); + CliError::NativeDiagnostic { + diagnostic: Box::new(diagnostic), + source: Box::new(error), + } +} + +#[cfg(all( + feature = "config", + any(target_os = "linux", target_os = "windows", target_os = "macos",) +))] +fn native_diagnostic_metadata(error: &CliError) -> (&'static str, Option<&'static str>) { + #[cfg(target_os = "macos")] + if let Some(native) = find_error::(error) { + return match native { + cageforge::MacosFilesystemError::ProgramRequiresRead { .. } => { + ("macos_program_requires_read", Some("command.program")) + } + cageforge::MacosFilesystemError::ProgramRequiresExecutableRoot { .. } => ( + "macos_program_requires_executable_root", + Some("runtime.executable_roots"), + ), + cageforge::MacosFilesystemError::ExecutableRootMissing { .. } + | cageforge::MacosFilesystemError::ExecutableRootNotDirectory { .. } + | cageforge::MacosFilesystemError::ExecutableRootNotReadable { .. } + | cageforge::MacosFilesystemError::InvalidExecutableRoot { .. } => ( + "macos_invalid_executable_root", + Some("runtime.executable_roots"), + ), + _ => ("macos_native_error", None), + }; + } + + #[cfg(target_os = "linux")] + let code = "linux_native_error"; + #[cfg(target_os = "windows")] + let code = "windows_native_error"; + #[cfg(target_os = "macos")] + let code = "macos_native_error"; + let _ = error; + (code, None) +} + +#[cfg(all(feature = "config", target_os = "macos"))] +fn find_error<'a, T: std::error::Error + 'static>( + error: &'a (dyn std::error::Error + 'static), +) -> Option<&'a T> { + if let Some(value) = error.downcast_ref::() { + return Some(value); + } + let mut source = error.source(); + while let Some(value) = source { + if let Some(value) = value.downcast_ref::() { + return Some(value); + } + source = value.source(); + } + None +} + #[cfg(all( feature = "config", not(any(target_os = "linux", target_os = "windows", target_os = "macos",)) @@ -597,8 +694,9 @@ fn execute_native(_invocation: Invocation) -> Result { effective, context, gateway, + source, } = _invocation; - drop((command, effective, context, gateway)); + drop((command, effective, context, gateway, source)); Err(CliError::NativeSandbox( cageforge::NativeSandboxError::UnsupportedPlatform { target_os: std::env::consts::OS, diff --git a/crates/cageforge-cli/tests/cli.rs b/crates/cageforge-cli/tests/cli.rs index cd2ae1c7..5dd9761c 100644 --- a/crates/cageforge-cli/tests/cli.rs +++ b/crates/cageforge-cli/tests/cli.rs @@ -3,7 +3,10 @@ use std::ffi::OsString; use std::process::Command as ProcessCommand; -use cageforge::{GrantAuthority, PermissionRequest, PermissionScope, PermissionSet, PlatformId}; +use cageforge::{ + Config, ConfigDiagnostic, GrantAuthority, PermissionRequest, PermissionScope, PermissionSet, + PlatformId, +}; #[cfg(target_os = "windows")] use cageforge_cli::SetupCommand; use cageforge_cli::{Cli, Command, RunArgs}; @@ -213,6 +216,60 @@ fn configuration_error_is_rendered_with_source_context() { insta::assert_snapshot!(stderr); } +#[test] +fn native_diagnostic_variants_are_snapshotted_with_source_context() { + let workspace = tempfile::tempdir().expect("temporary diagnostic configuration"); + let config_path = workspace.path().join("tool.toml"); + std::fs::write( + &config_path, + r#"default_profile = "tool" + +[profiles.tool.platforms.macos.command] +program = "/opt/tool/bin/runner" +args = ["--worker", "one"] + +[profiles.tool.platforms.macos.runtime] +executable_roots = ["/opt/tool/runtime"] +"#, + ) + .expect("write diagnostic fixture"); + let config = Config::from_file(&config_path).expect("valid diagnostic fixture"); + let profile = config + .resolve_for_platform("tool", PlatformId::Macos) + .expect("diagnostic fixture resolves"); + let source = profile + .source_context() + .clone() + .with_command("/opt/tool/bin/runner --worker one"); + + for (name, code, message, field) in [ + ( + "native_generic_failure", + "macos_native_error", + "sandbox preparation failed: native setup failed", + None, + ), + ( + "native_program_requires_read", + "macos_program_requires_read", + "macOS program requires a filesystem read rule: \"/opt/tool/bin/runner\"", + Some("command.program"), + ), + ( + "native_program_requires_executable_root", + "macos_program_requires_executable_root", + "macOS program requires runtime.executable_roots: \"/opt/tool/bin/runner\"", + Some("runtime.executable_roots"), + ), + ] { + let diagnostic = ConfigDiagnostic::for_runtime_failure(&source, code, message, field); + let rendered = diagnostic + .render_human() + .replace(&config_path.display().to_string(), ""); + insta::assert_snapshot!(name, rendered); + } +} + #[test] fn no_command_is_a_usage_error() { let output = ProcessCommand::new(env!("CARGO_BIN_EXE_cageforge-cli")) diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_generic_failure.snap b/crates/cageforge-cli/tests/snapshots/cli__native_generic_failure.snap new file mode 100644 index 00000000..5bad7781 --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_generic_failure.snap @@ -0,0 +1,9 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[macos_native_error] sandbox preparation failed: native setup failed + --> :3:1 + profile: tool + platform: macos + command: /opt/tool/bin/runner --worker one diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_program_requires_executable_root.snap b/crates/cageforge-cli/tests/snapshots/cli__native_program_requires_executable_root.snap new file mode 100644 index 00000000..cdf6e5cc --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_program_requires_executable_root.snap @@ -0,0 +1,10 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[macos_program_requires_executable_root] macOS program requires runtime.executable_roots: "/opt/tool/bin/runner" + --> :8:1 + profile: tool + platform: macos + command: /opt/tool/bin/runner --worker one + field: runtime.executable_roots diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_program_requires_read.snap b/crates/cageforge-cli/tests/snapshots/cli__native_program_requires_read.snap new file mode 100644 index 00000000..f17e46cf --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_program_requires_read.snap @@ -0,0 +1,10 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[macos_program_requires_read] macOS program requires a filesystem read rule: "/opt/tool/bin/runner" + --> :4:1 + profile: tool + platform: macos + command: /opt/tool/bin/runner --worker one + field: command.program diff --git a/crates/cageforge-config/src/diagnostics.rs b/crates/cageforge-config/src/diagnostics.rs index b8a64113..09a477a1 100644 --- a/crates/cageforge-config/src/diagnostics.rs +++ b/crates/cageforge-config/src/diagnostics.rs @@ -5,8 +5,9 @@ //! The diagnostic shape is intended for editors, CLIs, and JSON-producing //! integrations; typed [`crate::ConfigError`] remains the primary Rust error. -use crate::{ConfigError, SourceLocation}; +use crate::{ConfigError, ProfileSourceContext, SourceLocation}; use serde::Serialize; +use std::fmt; use std::path::Path; /// Severity of a configuration diagnostic. @@ -33,6 +34,8 @@ pub struct ConfigDiagnostic { config_path: Option, #[serde(skip_serializing_if = "Option::is_none")] platform: Option, + #[serde(skip_serializing_if = "Option::is_none")] + command: Option, } impl ConfigDiagnostic { @@ -76,6 +79,35 @@ impl ConfigDiagnostic { self.platform } + /// Returns the command associated with a runtime failure, when known. + pub fn command(&self) -> Option<&str> { + self.command.as_deref() + } + + /// Builds a diagnostic for a native failure while retaining the resolved + /// profile's source metadata. The native error itself remains the typed + /// source error owned by the caller. + pub fn for_runtime_failure( + source: &ProfileSourceContext, + code: &'static str, + message: impl Into, + field: Option<&str>, + ) -> Self { + Self { + code, + severity: DiagnosticSeverity::Error, + message: message.into(), + profile: Some(source.profile().to_owned()), + field: field.map(str::to_owned), + location: field + .and_then(|value| source.field_location(value)) + .or_else(|| source.profile_location()), + config_path: source.config_path().map(std::path::Path::to_path_buf), + platform: source.platform(), + command: source.command().map(str::to_owned), + } + } + /// Serializes this diagnostic as a JSON object. pub fn to_json(&self) -> Result { serde_json::to_string(self) @@ -98,6 +130,9 @@ impl ConfigDiagnostic { if let Some(platform) = self.platform { output.push_str(&format!("\n platform: {}", platform.as_str())); } + if let Some(command) = self.command.as_deref() { + output.push_str(&format!("\n command: {command}")); + } if let Some(field) = self.field.as_deref() { output.push_str(&format!("\n field: {field}")); } @@ -105,6 +140,12 @@ impl ConfigDiagnostic { } } +impl fmt::Display for ConfigDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.render_human()) + } +} + impl ConfigError { /// Converts this typed error into a stable diagnostic for UIs, CLIs, or /// structured application logs. @@ -147,6 +188,7 @@ impl ConfigError { .or(intrinsic_location), config_path: context.and_then(|value| value.config_path.clone()), platform: context.and_then(|value| value.platform), + command: None, } } } diff --git a/crates/cageforge-config/src/lib.rs b/crates/cageforge-config/src/lib.rs index b5f697d8..d0f6a2f7 100644 --- a/crates/cageforge-config/src/lib.rs +++ b/crates/cageforge-config/src/lib.rs @@ -33,7 +33,7 @@ mod schema; pub use diagnostics::{ConfigDiagnostic, DiagnosticSeverity}; pub use error::{ConfigError, ConfigErrorContext, SourceLocation}; -pub use resolve::{Config, ResolvedProfile}; +pub use resolve::{Config, ProfileSourceContext, ResolvedProfile}; pub use schema::config_schema_json; pub use cageforge_command::CommandRequest; diff --git a/crates/cageforge-config/src/resolve.rs b/crates/cageforge-config/src/resolve.rs index fbaf8a2d..ef4bbf0f 100644 --- a/crates/cageforge-config/src/resolve.rs +++ b/crates/cageforge-config/src/resolve.rs @@ -20,7 +20,7 @@ use cageforge_path::{ }; use cageforge_permissions::{ApprovalConfig, PermissionMode, PlatformId}; use cageforge_policy::SandboxPolicy; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; @@ -38,7 +38,7 @@ struct SourceDocument { } /// A fully resolved profile ready for a backend or harness adapter. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct ResolvedProfile { description: Option, workspace_roots: Vec, @@ -47,6 +47,41 @@ pub struct ResolvedProfile { command: Option, network_gateway: GatewayConfig, approval: ApprovalConfig, + source_context: ProfileSourceContext, +} + +impl PartialEq for ResolvedProfile { + fn eq(&self, other: &Self) -> bool { + self.description == other.description + && self.workspace_roots == other.workspace_roots + && self.executable_roots == other.executable_roots + && self.policy == other.policy + && self.command == other.command + && self.network_gateway == other.network_gateway + && self.approval == other.approval + } +} + +impl Eq for ResolvedProfile {} + +/// Source metadata retained with a resolved profile for runtime diagnostics. +/// +/// Backends receive validated policy values and must not parse TOML. This +/// context lets an adapter report where an effective value came from without +/// coupling native enforcement to the configuration format. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ProfileSourceContext { + profile: String, + #[serde(skip_serializing_if = "Option::is_none")] + config_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + platform: Option, + #[serde(skip_serializing_if = "Option::is_none")] + profile_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + command: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + field_locations: BTreeMap, } struct ResolveFrame { @@ -176,6 +211,7 @@ impl Config { }) .unwrap_or_default(); let approval = build_approval(merged.approval.as_ref(), name)?; + let source_context = self.source_context(name, platform, command.as_ref()); Ok(ResolvedProfile { description: merged.description, workspace_roots, @@ -184,6 +220,7 @@ impl Config { command, network_gateway, approval, + source_context, }) } @@ -297,64 +334,167 @@ impl Config { } Ok(merger.finish()) } + + fn source_context( + &self, + profile: &str, + platform: Option, + command: Option<&CommandRequest>, + ) -> ProfileSourceContext { + let fields = [ + "workspace_roots", + "filesystem", + "network", + "local_ipc", + "command", + "command.program", + "approval", + "runtime.executable_roots", + ] + .into_iter() + .filter_map(|field| { + self.source + .location_for(Some(profile), platform, Some(field)) + .map(|location| (field.to_owned(), location)) + }) + .collect(); + ProfileSourceContext { + profile: profile.to_owned(), + config_path: self.source.path.clone(), + platform, + profile_location: self.source.first_profile_location(profile, platform), + command: command.map(display_command), + field_locations: fields, + } + } } impl SourceDocument { - fn location_for( + fn first_profile_location( &self, - profile: Option<&str>, + profile: &str, platform: Option, - field: Option<&str>, ) -> Option { - let profile = profile?; - let base_header = match platform { - Some(platform) => format!("[profiles.{profile}.platforms.{}]", platform.as_str()), - None => format!("[profiles.{profile}]"), + let prefix = match platform { + Some(platform) => format!("[profiles.{profile}.platforms.{}", platform.as_str()), + None => format!("[profiles.{profile}"), }; - let field_prefix = field.and_then(|value| value.rsplit_once('.').map(|(prefix, _)| prefix)); - let nested_header = field_prefix.map(|prefix| format!("{base_header}.{prefix}")); - let headers = nested_header - .as_deref() - .into_iter() - .chain(std::iter::once(base_header.as_str())); - let mut section_start = None; let mut offset = 0; for line in self.text.split_inclusive('\n') { - let current = line.trim().trim_end_matches('\n'); - if headers.clone().any(|header| current == header) { - section_start = Some(offset); - break; + let current = line.trim(); + let matches = current.starts_with(&prefix) + && matches!(current.as_bytes().get(prefix.len()), Some(b'.' | b']')); + if matches { + return Some(source_location(&self.text, offset..offset + current.len())); } offset += line.len(); } - let section_start = section_start?; - let section = &self.text[section_start..]; + None + } + + fn location_for( + &self, + profile: Option<&str>, + platform: Option, + field: Option<&str>, + ) -> Option { + let profile = profile?; let key = field .and_then(|value| value.rsplit('.').next()) .unwrap_or_default(); - let mut line_offset = section_start; - for line in section.split_inclusive('\n') { - if line_offset != section_start && line.trim_start().starts_with('[') { - break; + let prefix = field.and_then(|value| value.rsplit_once('.').map(|(prefix, _)| prefix)); + let mut nested_prefixes = Vec::new(); + if let Some(field) = field { + nested_prefixes.push(field); + } + if let Some(prefix) = prefix + && !nested_prefixes.contains(&prefix) + { + nested_prefixes.push(prefix); + } + let mut headers = Vec::new(); + if let Some(platform) = platform { + let root = format!("profiles.{profile}.platforms.{}", platform.as_str()); + for prefix in &nested_prefixes { + headers.push(format!("[{root}.{prefix}]")); } - let trimmed = line.trim_start(); - if !key.is_empty() - && trimmed.starts_with(key) - && trimmed[key.len()..].trim_start().starts_with('=') - { - let start = line_offset + line.len() - line.trim_start().len(); - let end = start + key.len(); - return Some(source_location(&self.text, start..end)); + headers.push(format!("[{root}]")); + } + let root = format!("profiles.{profile}"); + for prefix in &nested_prefixes { + headers.push(format!("[{root}.{prefix}]")); + } + headers.push(format!("[{root}]")); + + let sections = headers.iter().filter_map(|header| { + self.section(header) + .map(|(start, end)| (start, end, header.len())) + }); + let mut fallback = None; + for (section_start, section_end, header_length) in sections { + fallback.get_or_insert((section_start, header_length)); + if let Some(location) = find_key_location(&self.text, section_start, section_end, key) { + return Some(location); + } + } + fallback + .map(|(start, header_length)| source_location(&self.text, start..start + header_length)) + } + + fn section(&self, header: &str) -> Option<(usize, usize)> { + let mut offset = 0; + let mut start = None; + for line in self.text.split_inclusive('\n') { + let current = line.trim().trim_end_matches('\n'); + if start.is_none() { + if current == header { + start = Some(offset); + } + } else if line.trim_start().starts_with('[') { + return Some((start?, offset)); } - line_offset += line.len(); + offset += line.len(); } - Some(source_location( - &self.text, - section_start..section_start + base_header.len(), - )) + start.map(|value| (value, self.text.len())) } } +fn find_key_location( + source: &str, + section_start: usize, + section_end: usize, + key: &str, +) -> Option { + if key.is_empty() { + return None; + } + let section = &source[section_start..section_end]; + let mut line_offset = section_start; + for line in section.split_inclusive('\n') { + let trimmed = line.trim_start(); + if trimmed.starts_with(key) && trimmed[key.len()..].trim_start().starts_with('=') { + let start = line_offset + line.len() - line.trim_start().len(); + return Some(source_location(source, start..start + key.len())); + } + line_offset += line.len(); + } + None +} + +fn display_command(command: &CommandRequest) -> String { + std::iter::once(command.command().program()) + .chain( + command + .command() + .args() + .iter() + .map(std::ffi::OsString::as_os_str), + ) + .map(|part| part.to_string_lossy().into_owned()) + .collect::>() + .join(" ") +} + impl ResolvedProfile { /// Returns the selected profile's description, if configured. pub fn description(&self) -> Option<&str> { @@ -395,6 +535,49 @@ impl ResolvedProfile { pub fn approval(&self) -> ApprovalConfig { self.approval } + + /// Returns source metadata for diagnostics produced after resolution. + pub fn source_context(&self) -> &ProfileSourceContext { + &self.source_context + } +} + +impl ProfileSourceContext { + /// Returns the resolved profile name. + pub fn profile(&self) -> &str { + &self.profile + } + + /// Returns the source TOML path, when the profile was loaded from a file. + pub fn config_path(&self) -> Option<&Path> { + self.config_path.as_deref() + } + + /// Returns the selected platform overlay. + pub const fn platform(&self) -> Option { + self.platform + } + + /// Returns the effective command rendered for diagnostics. + pub fn command(&self) -> Option<&str> { + self.command.as_deref() + } + + /// Returns the source location for a logical field, when it was found. + pub fn field_location(&self, field: &str) -> Option { + self.field_locations.get(field).copied() + } + + /// Returns the profile header location used as a fallback diagnostic span. + pub const fn profile_location(&self) -> Option { + self.profile_location + } + + /// Replaces the displayed command after a CLI argv override. + pub fn with_command(mut self, command: impl Into) -> Self { + self.command = Some(command.into()); + self + } } impl ResolveFrame { diff --git a/crates/cageforge-config/tests/config.rs b/crates/cageforge-config/tests/config.rs index 1908f691..1bc95b5d 100644 --- a/crates/cageforge-config/tests/config.rs +++ b/crates/cageforge-config/tests/config.rs @@ -238,15 +238,17 @@ dns_timeout_ms = 0 .expect("zero is structurally valid TOML") .resolve("safe") .expect_err("zero timeout must fail semantic validation"); - assert_eq!( + assert!(matches!( zero, ConfigError::InvalidValue { - profile: "safe".to_owned(), - field: "network.gateway.dns_timeout_ms".to_owned(), - value: "0 must be representable and greater than zero".to_owned(), - context: None, - } - ); + profile, + field, + value, + context: Some(_), + } if profile == "safe" + && field == "network.gateway.dns_timeout_ms" + && value == "0 must be representable and greater than zero" + )); let small_header = Config::from_toml( r#" @@ -257,17 +259,19 @@ http_header_bytes = 4096 .expect("small header is structurally valid TOML") .resolve("safe") .expect_err("small HTTP parser buffer must fail semantic validation"); - assert_eq!( + assert!(matches!( small_header, ConfigError::NetworkGateway { - profile: "safe".to_owned(), + profile, source: GatewayConfigError::HttpHeaderLimitTooSmall { - minimum: 8192, - actual: 4096, + minimum, + actual, }, - context: None, - } - ); + context: Some(_), + } if profile == "safe" + && minimum == 8192 + && actual == 4096 + )); let invalid_mode = Config::from_toml( r#" diff --git a/specs/0026-structured-configuration-diagnostics.md b/specs/0026-structured-configuration-diagnostics.md index e1031267..d7a6c45c 100644 --- a/specs/0026-structured-configuration-diagnostics.md +++ b/specs/0026-structured-configuration-diagnostics.md @@ -26,8 +26,12 @@ Each `ConfigError` may carry `ConfigErrorContext` containing: - the profile and logical field identified by the error. `ConfigDiagnostic` exposes a stable code, severity, message, profile, field, -platform, path, and source location. `to_json()` is for machines; the CLI -uses `render_human()` and must not require callers to parse a display string. +platform, command, path, and source location. `to_json()` is for machines; the +CLI uses `render_human()` and must not require callers to parse a display +string. A resolved profile retains source metadata separately from its +semantic policy value. Native adapters use that metadata to attach the +selected profile, effective command, logical field, and TOML line/column to a +backend failure without making the backend parse TOML. ## Native boundary @@ -36,7 +40,9 @@ before process creation. For macOS, Seatbelt owns the fixed executable baseline and checks absolute custom programs against effective read access and explicit `runtime.executable_roots`. It returns typed errors such as `ProgramRequiresRead` and `ProgramRequiresExecutableRoot`; config does not -duplicate the Seatbelt baseline. +duplicate the Seatbelt baseline. The CLI and host-language adapters may wrap +the typed native cause in a source-aware diagnostic, but must retain the +original typed error for programmatic inspection. ## Foreign bindings @@ -50,7 +56,9 @@ the public error protocol. ## Verification Rust tests cover typed variants, source context, platform overlays, and native -preflight failures. CLI tests use snapshots for the human rendering. Python -and Java tests verify the stable exception category and metadata. Native jobs -remain responsible for executing the backend-specific checks on their actual +preflight failures. CLI snapshots cover generic native failures, missing +filesystem read access, and missing executable-root declarations, including +the platform, command, profile, field, and source location. Python and Java +tests verify the stable exception category and metadata. Native jobs remain +responsible for executing the backend-specific checks on their actual operating system. From 06ce31e2bfb81a4b4093d69421b35f1d7cae7632 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Sun, 20 Sep 2026 20:11:41 +0500 Subject: [PATCH 07/13] test(cli): snapshot all native diagnostic variants Co-authored-by: codex --- crates/cageforge-cli/tests/cli.rs | 14 ++++++++++---- .../cli__native_invalid_executable_root.snap | 10 ++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_invalid_executable_root.snap diff --git a/crates/cageforge-cli/tests/cli.rs b/crates/cageforge-cli/tests/cli.rs index 5dd9761c..9af551e4 100644 --- a/crates/cageforge-cli/tests/cli.rs +++ b/crates/cageforge-cli/tests/cli.rs @@ -3,10 +3,9 @@ use std::ffi::OsString; use std::process::Command as ProcessCommand; -use cageforge::{ - Config, ConfigDiagnostic, GrantAuthority, PermissionRequest, PermissionScope, PermissionSet, - PlatformId, -}; +#[cfg(feature = "config")] +use cageforge::{Config, ConfigDiagnostic}; +use cageforge::{GrantAuthority, PermissionRequest, PermissionScope, PermissionSet, PlatformId}; #[cfg(target_os = "windows")] use cageforge_cli::SetupCommand; use cageforge_cli::{Cli, Command, RunArgs}; @@ -216,6 +215,7 @@ fn configuration_error_is_rendered_with_source_context() { insta::assert_snapshot!(stderr); } +#[cfg(feature = "config")] #[test] fn native_diagnostic_variants_are_snapshotted_with_source_context() { let workspace = tempfile::tempdir().expect("temporary diagnostic configuration"); @@ -261,6 +261,12 @@ executable_roots = ["/opt/tool/runtime"] "macOS program requires runtime.executable_roots: \"/opt/tool/bin/runner\"", Some("runtime.executable_roots"), ), + ( + "native_invalid_executable_root", + "macos_invalid_executable_root", + "configured executable runtime root is not an absolute safe path: \"relative/runtime\"", + Some("runtime.executable_roots"), + ), ] { let diagnostic = ConfigDiagnostic::for_runtime_failure(&source, code, message, field); let rendered = diagnostic diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_invalid_executable_root.snap b/crates/cageforge-cli/tests/snapshots/cli__native_invalid_executable_root.snap new file mode 100644 index 00000000..9aa940fe --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_invalid_executable_root.snap @@ -0,0 +1,10 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[macos_invalid_executable_root] configured executable runtime root is not an absolute safe path: "relative/runtime" + --> :8:1 + profile: tool + platform: macos + command: /opt/tool/bin/runner --worker one + field: runtime.executable_roots From 0ba441896bff15c1039700af73d8f70884193798 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 21 Sep 2026 11:00:13 +0500 Subject: [PATCH 08/13] test(cli): snapshot all native diagnostic variants Co-authored-by: codex --- crates/cageforge-cli/tests/cli.rs | 67 +++++++++++++++++++ .../cli__native_executable_root_missing.snap | 10 +++ ..._native_executable_root_not_directory.snap | 10 +++ ...__native_executable_root_not_readable.snap | 10 +++ .../snapshots/cli__native_linux_failure.snap | 9 +++ .../cli__native_windows_failure.snap | 9 +++ crates/cageforge-config/src/resolve.rs | 31 +++++---- 7 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_executable_root_missing.snap create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_executable_root_not_directory.snap create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_executable_root_not_readable.snap create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_linux_failure.snap create mode 100644 crates/cageforge-cli/tests/snapshots/cli__native_windows_failure.snap diff --git a/crates/cageforge-cli/tests/cli.rs b/crates/cageforge-cli/tests/cli.rs index 9af551e4..6aab7318 100644 --- a/crates/cageforge-cli/tests/cli.rs +++ b/crates/cageforge-cli/tests/cli.rs @@ -261,6 +261,24 @@ executable_roots = ["/opt/tool/runtime"] "macOS program requires runtime.executable_roots: \"/opt/tool/bin/runner\"", Some("runtime.executable_roots"), ), + ( + "native_executable_root_missing", + "macos_invalid_executable_root", + "configured executable runtime root is missing: \"/opt/tool/runtime\"", + Some("runtime.executable_roots"), + ), + ( + "native_executable_root_not_directory", + "macos_invalid_executable_root", + "configured executable runtime root is not a directory: \"/opt/tool/runtime\"", + Some("runtime.executable_roots"), + ), + ( + "native_executable_root_not_readable", + "macos_invalid_executable_root", + "configured executable runtime root is not readable: \"/opt/tool/runtime\"", + Some("runtime.executable_roots"), + ), ( "native_invalid_executable_root", "macos_invalid_executable_root", @@ -276,6 +294,55 @@ executable_roots = ["/opt/tool/runtime"] } } +#[cfg(feature = "config")] +#[test] +fn native_platform_diagnostics_are_snapshotted() { + let workspace = tempfile::tempdir().expect("temporary platform diagnostic configuration"); + let config_path = workspace.path().join("platforms.toml"); + std::fs::write( + &config_path, + r#"default_profile = "tool" + +[profiles.tool.command] +program = "/opt/tool/bin/runner" +args = ["--worker", "one"] +"#, + ) + .expect("write platform diagnostic fixture"); + let config = Config::from_file(&config_path).expect("valid platform diagnostic fixture"); + + for (name, platform, code) in [ + ( + "native_linux_failure", + PlatformId::Linux, + "linux_native_error", + ), + ( + "native_windows_failure", + PlatformId::Windows, + "windows_native_error", + ), + ] { + let profile = config + .resolve_for_platform("tool", platform) + .expect("platform diagnostic fixture resolves"); + let source = profile + .source_context() + .clone() + .with_command("/opt/tool/bin/runner --worker one"); + let diagnostic = ConfigDiagnostic::for_runtime_failure( + &source, + code, + "sandbox preparation failed: native setup failed", + None, + ); + let rendered = diagnostic + .render_human() + .replace(&config_path.display().to_string(), ""); + insta::assert_snapshot!(name, rendered); + } +} + #[test] fn no_command_is_a_usage_error() { let output = ProcessCommand::new(env!("CARGO_BIN_EXE_cageforge-cli")) diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_missing.snap b/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_missing.snap new file mode 100644 index 00000000..18962533 --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_missing.snap @@ -0,0 +1,10 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[macos_invalid_executable_root] configured executable runtime root is missing: "/opt/tool/runtime" + --> :8:1 + profile: tool + platform: macos + command: /opt/tool/bin/runner --worker one + field: runtime.executable_roots diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_not_directory.snap b/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_not_directory.snap new file mode 100644 index 00000000..d17adcfd --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_not_directory.snap @@ -0,0 +1,10 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[macos_invalid_executable_root] configured executable runtime root is not a directory: "/opt/tool/runtime" + --> :8:1 + profile: tool + platform: macos + command: /opt/tool/bin/runner --worker one + field: runtime.executable_roots diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_not_readable.snap b/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_not_readable.snap new file mode 100644 index 00000000..262f39c8 --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_executable_root_not_readable.snap @@ -0,0 +1,10 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[macos_invalid_executable_root] configured executable runtime root is not readable: "/opt/tool/runtime" + --> :8:1 + profile: tool + platform: macos + command: /opt/tool/bin/runner --worker one + field: runtime.executable_roots diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_linux_failure.snap b/crates/cageforge-cli/tests/snapshots/cli__native_linux_failure.snap new file mode 100644 index 00000000..14a92576 --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_linux_failure.snap @@ -0,0 +1,9 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[linux_native_error] sandbox preparation failed: native setup failed + --> :3:1 + profile: tool + platform: linux + command: /opt/tool/bin/runner --worker one diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_windows_failure.snap b/crates/cageforge-cli/tests/snapshots/cli__native_windows_failure.snap new file mode 100644 index 00000000..6b89690a --- /dev/null +++ b/crates/cageforge-cli/tests/snapshots/cli__native_windows_failure.snap @@ -0,0 +1,9 @@ +--- +source: crates/cageforge-cli/tests/cli.rs +expression: rendered +--- +[windows_native_error] sandbox preparation failed: native setup failed + --> :3:1 + profile: tool + platform: windows + command: /opt/tool/bin/runner --worker one diff --git a/crates/cageforge-config/src/resolve.rs b/crates/cageforge-config/src/resolve.rs index ef4bbf0f..a7e75e93 100644 --- a/crates/cageforge-config/src/resolve.rs +++ b/crates/cageforge-config/src/resolve.rs @@ -375,19 +375,26 @@ impl SourceDocument { profile: &str, platform: Option, ) -> Option { - let prefix = match platform { - Some(platform) => format!("[profiles.{profile}.platforms.{}", platform.as_str()), - None => format!("[profiles.{profile}"), - }; - let mut offset = 0; - for line in self.text.split_inclusive('\n') { - let current = line.trim(); - let matches = current.starts_with(&prefix) - && matches!(current.as_bytes().get(prefix.len()), Some(b'.' | b']')); - if matches { - return Some(source_location(&self.text, offset..offset + current.len())); + let prefixes = platform + .map(|platform| { + vec![ + format!("[profiles.{profile}.platforms.{}", platform.as_str()), + format!("[profiles.{profile}"), + ] + }) + .unwrap_or_else(|| vec![format!("[profiles.{profile}")]); + + for prefix in prefixes { + let mut offset = 0; + for line in self.text.split_inclusive('\n') { + let current = line.trim(); + let matches = current.starts_with(&prefix) + && matches!(current.as_bytes().get(prefix.len()), Some(b'.' | b']')); + if matches { + return Some(source_location(&self.text, offset..offset + current.len())); + } + offset += line.len(); } - offset += line.len(); } None } From 17efab4ba68c9892ecc5f3dcb57931d4126c1a65 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 21 Sep 2026 11:16:43 +0500 Subject: [PATCH 09/13] fix(config): preserve base source locations for platform errors Co-authored-by: codex --- crates/cageforge-cli/tests/cli.rs | 4 ++++ .../snapshots/cli__native_linux_failure.snap | 2 +- .../cli__native_windows_failure.snap | 2 +- crates/cageforge-config/src/resolve.rs | 19 +++++++++++++------ 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/cageforge-cli/tests/cli.rs b/crates/cageforge-cli/tests/cli.rs index 6aab7318..67c9f11e 100644 --- a/crates/cageforge-cli/tests/cli.rs +++ b/crates/cageforge-cli/tests/cli.rs @@ -303,6 +303,10 @@ fn native_platform_diagnostics_are_snapshotted() { &config_path, r#"default_profile = "tool" +[profiles.tool.platforms.macos.command] +program = "/opt/tool/bin/macos-runner" +args = ["--macos"] + [profiles.tool.command] program = "/opt/tool/bin/runner" args = ["--worker", "one"] diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_linux_failure.snap b/crates/cageforge-cli/tests/snapshots/cli__native_linux_failure.snap index 14a92576..10e481e2 100644 --- a/crates/cageforge-cli/tests/snapshots/cli__native_linux_failure.snap +++ b/crates/cageforge-cli/tests/snapshots/cli__native_linux_failure.snap @@ -3,7 +3,7 @@ source: crates/cageforge-cli/tests/cli.rs expression: rendered --- [linux_native_error] sandbox preparation failed: native setup failed - --> :3:1 + --> :7:1 profile: tool platform: linux command: /opt/tool/bin/runner --worker one diff --git a/crates/cageforge-cli/tests/snapshots/cli__native_windows_failure.snap b/crates/cageforge-cli/tests/snapshots/cli__native_windows_failure.snap index 6b89690a..23aef497 100644 --- a/crates/cageforge-cli/tests/snapshots/cli__native_windows_failure.snap +++ b/crates/cageforge-cli/tests/snapshots/cli__native_windows_failure.snap @@ -3,7 +3,7 @@ source: crates/cageforge-cli/tests/cli.rs expression: rendered --- [windows_native_error] sandbox preparation failed: native setup failed - --> :3:1 + --> :7:1 profile: tool platform: windows command: /opt/tool/bin/runner --worker one diff --git a/crates/cageforge-config/src/resolve.rs b/crates/cageforge-config/src/resolve.rs index a7e75e93..806d464f 100644 --- a/crates/cageforge-config/src/resolve.rs +++ b/crates/cageforge-config/src/resolve.rs @@ -375,21 +375,28 @@ impl SourceDocument { profile: &str, platform: Option, ) -> Option { + let base_prefix = format!("[profiles.{profile}"); let prefixes = platform .map(|platform| { vec![ - format!("[profiles.{profile}.platforms.{}", platform.as_str()), - format!("[profiles.{profile}"), + ( + format!("[profiles.{profile}.platforms.{}", platform.as_str()), + false, + ), + (base_prefix.clone(), true), ] }) - .unwrap_or_else(|| vec![format!("[profiles.{profile}")]); + .unwrap_or_else(|| vec![(base_prefix, true)]); - for prefix in prefixes { + for (prefix, is_base) in prefixes { let mut offset = 0; for line in self.text.split_inclusive('\n') { let current = line.trim(); - let matches = current.starts_with(&prefix) - && matches!(current.as_bytes().get(prefix.len()), Some(b'.' | b']')); + let suffix = current.strip_prefix(&prefix); + let matches = suffix.is_some_and(|suffix| { + matches!(suffix.as_bytes().first(), Some(b'.' | b']')) + && !(is_base && suffix.starts_with(".platforms")) + }); if matches { return Some(source_location(&self.text, offset..offset + current.len())); } From ce9eed9d21f3af1d4c3ccc2a556855da018c1ce5 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 21 Sep 2026 11:25:10 +0500 Subject: [PATCH 10/13] test(macos): synchronize leader exit fixture Co-authored-by: codex --- crates/cageforge-macos/tests/macos_backend.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/cageforge-macos/tests/macos_backend.rs b/crates/cageforge-macos/tests/macos_backend.rs index 67d48bcc..4ea10aa7 100644 --- a/crates/cageforge-macos/tests/macos_backend.rs +++ b/crates/cageforge-macos/tests/macos_backend.rs @@ -441,6 +441,19 @@ fn request_for( CommandRequest, cageforge_policy_compose::EffectiveSandbox, PathResolutionContext, +) { + request_for_with_stdin(workspace, policy, command, StdioMode::Inherit) +} + +fn request_for_with_stdin( + workspace: &Path, + policy: &SandboxPolicy, + command: CommandSpec, + stdin_mode: StdioMode, +) -> ( + CommandRequest, + cageforge_policy_compose::EffectiveSandbox, + PathResolutionContext, ) { let environment = EnvironmentSpec::inherit_core(); let policy = policy_for_command(policy, Path::new(command.program())); @@ -452,6 +465,7 @@ fn request_for( .expect("working directory") .with_stdio( StdioSpec::inherited() + .with_stdin(stdin_mode) .with_stdout(StdioMode::Pipe) .with_stderr(StdioMode::Pipe), ) @@ -755,7 +769,7 @@ fn exiting_marker_child( .expect("shell option") .with_arg( format!("(sleep {MARKER_DELAY_SECONDS}; touch \"$1\") & descendant=$!; ") - + "printf 'ready:%s\\n' \"$descendant\"; exit 0", + + "printf 'ready:%s\\n' \"$descendant\"; IFS= read -r _; exit 0", ) .expect("shell script") .with_arg("cageforge-marker") @@ -763,7 +777,8 @@ fn exiting_marker_child( .with_arg(marker.as_os_str()) .expect("marker argument"); let policy = writable_policy(workspace); - let (command, effective, context) = request_for(workspace, &policy, command); + let (command, effective, context) = + request_for_with_stdin(workspace, &policy, command, StdioMode::Pipe); let prepared = backend .prepare(BackendRequest::new(&command, &effective), &context) .expect("prepare"); @@ -1964,6 +1979,11 @@ fn reaped_leader_does_not_leave_a_running_descendant() { "descendant {descendant} escaped boundary group {}", child.id() ); + child + .stdin() + .expect("leader stdin pipe") + .write_all(b"release\n") + .expect("release leader fixture"); assert!(child.wait().expect("wait").success()); thread::sleep(Duration::from_secs(2)); assert!( From 127f4ffdfddfda9a990d9c67c8a580f2278216ef Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 21 Sep 2026 11:39:29 +0500 Subject: [PATCH 11/13] fix(diagnostics): unify native binding metadata Co-authored-by: codex --- crates/bindings/cageforge-java/src/error.rs | 17 ++- crates/bindings/cageforge-java/src/lib.rs | 11 +- crates/bindings/cageforge-python/src/lib.rs | 11 +- crates/cageforge-cli/src/execution.rs | 63 ++--------- crates/cageforge/src/lib.rs | 4 +- crates/cageforge/src/native.rs | 102 ++++++++++++++++++ crates/cageforge/tests/facade.rs | 76 +++++++++++++ ...26-structured-configuration-diagnostics.md | 27 ++--- 8 files changed, 218 insertions(+), 93 deletions(-) diff --git a/crates/bindings/cageforge-java/src/error.rs b/crates/bindings/cageforge-java/src/error.rs index 6f65f6dc..7a82af97 100644 --- a/crates/bindings/cageforge-java/src/error.rs +++ b/crates/bindings/cageforge-java/src/error.rs @@ -125,16 +125,15 @@ impl BindingDiagnostic { pub(crate) fn from_runtime( source: &cageforge::ProfileSourceContext, command: &cageforge::CommandRequest, - kind: &'static str, - message: impl Into, + error: &(dyn std::error::Error + 'static), ) -> (Self, String) { let source = source.clone().with_command(display_command(command)); - let message = message.into(); + let metadata = cageforge::native_diagnostic_metadata(error); let diagnostic = cageforge::ConfigDiagnostic::for_runtime_failure( &source, - kind, - message, - Some("command.program"), + metadata.code(), + error.to_string(), + metadata.field(), ); let location = diagnostic.location(); ( @@ -200,11 +199,9 @@ impl BindingError { kind: BindingErrorKind, source: &cageforge::ProfileSourceContext, command: &cageforge::CommandRequest, - code: &'static str, - message: impl Into, + error: &(dyn std::error::Error + 'static), ) -> Self { - let (diagnostic, rendered) = - BindingDiagnostic::from_runtime(source, command, code, message); + let (diagnostic, rendered) = BindingDiagnostic::from_runtime(source, command, error); Self { message: rendered, kind, diff --git a/crates/bindings/cageforge-java/src/lib.rs b/crates/bindings/cageforge-java/src/lib.rs index add3ece7..37b012ed 100644 --- a/crates/bindings/cageforge-java/src/lib.rs +++ b/crates/bindings/cageforge-java/src/lib.rs @@ -1310,13 +1310,7 @@ pub extern "system" fn Java_ai_cageforge_NativeBridge_nativeLaunch<'caller>( .backend .launch(backend_request, &runtime.context) .map_err(|error| { - BindingError::runtime( - BindingErrorKind::Launch, - &runtime.source, - &request, - "native_launch_error", - error.to_string(), - ) + BindingError::runtime(BindingErrorKind::Launch, &runtime.source, &request, &error) })?; let stdin = child.take_stdin(); let stdout = child.take_stdout(); @@ -1370,8 +1364,7 @@ pub extern "system" fn Java_ai_cageforge_NativeBridge_nativeLaunchEscalated<'cal BindingErrorKind::Escalation, &runtime.source, &request, - "native_escalation_error", - error.to_string(), + &error, ) })?; Ok(Box::into_raw(Box::new(ChildState { diff --git a/crates/bindings/cageforge-python/src/lib.rs b/crates/bindings/cageforge-python/src/lib.rs index 3439a244..54a715f9 100644 --- a/crates/bindings/cageforge-python/src/lib.rs +++ b/crates/bindings/cageforge-python/src/lib.rs @@ -394,14 +394,15 @@ fn launch_error(error: impl ToString) -> PyErr { fn launch_diagnostic_error( source: &cageforge::ProfileSourceContext, command: &cageforge::CommandRequest, - error: impl ToString, + error: &(dyn std::error::Error + 'static), ) -> PyErr { let source = source.clone().with_command(display_command(command)); + let metadata = cageforge::native_diagnostic_metadata(error); let diagnostic = cageforge::ConfigDiagnostic::for_runtime_failure( &source, - "native_launch_error", + metadata.code(), error.to_string(), - Some("command.program"), + metadata.field(), ); let exception = CageforgeLaunchError::new_err(diagnostic.render_human()); Python::attach(|py| { @@ -1268,7 +1269,7 @@ impl Cageforge { let mut child = runtime .backend .launch(backend_request, &runtime.context) - .map_err(|error| launch_diagnostic_error(&source, &request, error))?; + .map_err(|error| launch_diagnostic_error(&source, &request, &error))?; Ok::<_, PyErr>(ChildState { stdin: Mutex::new(child.take_stdin()), stdout: Mutex::new(child.take_stdout()), @@ -1368,7 +1369,7 @@ impl Cageforge { let mut child = runtime .backend .launch(backend_request, &context) - .map_err(|error| launch_diagnostic_error(&source, &request, error))?; + .map_err(|error| launch_diagnostic_error(&source, &request, &error))?; Ok::<_, PyErr>(ChildState { stdin: Mutex::new(child.take_stdin()), stdout: Mutex::new(child.take_stdout()), diff --git a/crates/cageforge-cli/src/execution.rs b/crates/cageforge-cli/src/execution.rs index 2ddb0a58..90807183 100644 --- a/crates/cageforge-cli/src/execution.rs +++ b/crates/cageforge-cli/src/execution.rs @@ -622,68 +622,19 @@ fn execute_native_inner(invocation: Invocation) -> Result { any(target_os = "linux", target_os = "windows", target_os = "macos",) ))] fn enrich_native_error(source: cageforge::ProfileSourceContext, error: CliError) -> CliError { - let (code, field) = native_diagnostic_metadata(&error); - let diagnostic = - cageforge::ConfigDiagnostic::for_runtime_failure(&source, code, error.to_string(), field); + let metadata = cageforge::native_diagnostic_metadata(&error); + let diagnostic = cageforge::ConfigDiagnostic::for_runtime_failure( + &source, + metadata.code(), + error.to_string(), + metadata.field(), + ); CliError::NativeDiagnostic { diagnostic: Box::new(diagnostic), source: Box::new(error), } } -#[cfg(all( - feature = "config", - any(target_os = "linux", target_os = "windows", target_os = "macos",) -))] -fn native_diagnostic_metadata(error: &CliError) -> (&'static str, Option<&'static str>) { - #[cfg(target_os = "macos")] - if let Some(native) = find_error::(error) { - return match native { - cageforge::MacosFilesystemError::ProgramRequiresRead { .. } => { - ("macos_program_requires_read", Some("command.program")) - } - cageforge::MacosFilesystemError::ProgramRequiresExecutableRoot { .. } => ( - "macos_program_requires_executable_root", - Some("runtime.executable_roots"), - ), - cageforge::MacosFilesystemError::ExecutableRootMissing { .. } - | cageforge::MacosFilesystemError::ExecutableRootNotDirectory { .. } - | cageforge::MacosFilesystemError::ExecutableRootNotReadable { .. } - | cageforge::MacosFilesystemError::InvalidExecutableRoot { .. } => ( - "macos_invalid_executable_root", - Some("runtime.executable_roots"), - ), - _ => ("macos_native_error", None), - }; - } - - #[cfg(target_os = "linux")] - let code = "linux_native_error"; - #[cfg(target_os = "windows")] - let code = "windows_native_error"; - #[cfg(target_os = "macos")] - let code = "macos_native_error"; - let _ = error; - (code, None) -} - -#[cfg(all(feature = "config", target_os = "macos"))] -fn find_error<'a, T: std::error::Error + 'static>( - error: &'a (dyn std::error::Error + 'static), -) -> Option<&'a T> { - if let Some(value) = error.downcast_ref::() { - return Some(value); - } - let mut source = error.source(); - while let Some(value) = source { - if let Some(value) = value.downcast_ref::() { - return Some(value); - } - source = value.source(); - } - None -} - #[cfg(all( feature = "config", not(any(target_os = "linux", target_os = "windows", target_os = "macos",)) diff --git a/crates/cageforge/src/lib.rs b/crates/cageforge/src/lib.rs index 945c6a05..561f4e36 100644 --- a/crates/cageforge/src/lib.rs +++ b/crates/cageforge/src/lib.rs @@ -21,9 +21,11 @@ mod native; mod preflight; +pub use native::{ + NativeDiagnosticMetadata, NativeSandboxError, native_diagnostic_metadata, native_sandbox, +}; #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] pub use native::{NativeSandboxConfig, native_sandbox_with}; -pub use native::{NativeSandboxError, native_sandbox}; pub use cageforge_backend_api::*; pub use cageforge_command::*; diff --git a/crates/cageforge/src/native.rs b/crates/cageforge/src/native.rs index 19d0c906..d76c6184 100644 --- a/crates/cageforge/src/native.rs +++ b/crates/cageforge/src/native.rs @@ -6,6 +6,25 @@ use std::error::Error; use crate::DynSandbox; +/// Stable source-field metadata for a native launch failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NativeDiagnosticMetadata { + code: &'static str, + field: Option<&'static str>, +} + +impl NativeDiagnosticMetadata { + /// Returns the stable native diagnostic code. + pub const fn code(self) -> &'static str { + self.code + } + + /// Returns the logical configuration field associated with the failure. + pub const fn field(self) -> Option<&'static str> { + self.field + } +} + /// Configuration of the backend selected for the compilation target. #[cfg(target_os = "linux")] pub use cageforge_linux::LinuxBackendConfig as NativeSandboxConfig; @@ -34,6 +53,89 @@ pub enum NativeSandboxError { }, } +/// Classifies a typed native failure for source-aware adapters. +/// +/// This function only selects stable presentation metadata. The original +/// error remains the typed source error and must be retained by the caller. +pub fn native_diagnostic_metadata(error: &(dyn Error + 'static)) -> NativeDiagnosticMetadata { + #[cfg(target_os = "macos")] + { + if let Some(native) = find_error::(error) { + return match native { + cageforge_macos::MacosFilesystemError::ProgramRequiresRead { .. } => { + NativeDiagnosticMetadata { + code: "macos_program_requires_read", + field: Some("command.program"), + } + } + cageforge_macos::MacosFilesystemError::ProgramRequiresExecutableRoot { .. } => { + NativeDiagnosticMetadata { + code: "macos_program_requires_executable_root", + field: Some("runtime.executable_roots"), + } + } + cageforge_macos::MacosFilesystemError::ExecutableRootMissing { .. } + | cageforge_macos::MacosFilesystemError::ExecutableRootNotDirectory { .. } + | cageforge_macos::MacosFilesystemError::ExecutableRootNotReadable { .. } + | cageforge_macos::MacosFilesystemError::InvalidExecutableRoot { .. } => { + NativeDiagnosticMetadata { + code: "macos_invalid_executable_root", + field: Some("runtime.executable_roots"), + } + } + _ => NativeDiagnosticMetadata { + code: "macos_native_error", + field: None, + }, + }; + } + NativeDiagnosticMetadata { + code: "macos_native_error", + field: None, + } + } + + #[cfg(target_os = "linux")] + { + let _ = error; + NativeDiagnosticMetadata { + code: "linux_native_error", + field: None, + } + } + #[cfg(target_os = "windows")] + { + let _ = error; + NativeDiagnosticMetadata { + code: "windows_native_error", + field: None, + } + } + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] + { + let _ = error; + NativeDiagnosticMetadata { + code: "native_error", + field: None, + } + } +} + +#[cfg(target_os = "macos")] +fn find_error<'a, T: Error + 'static>(error: &'a (dyn Error + 'static)) -> Option<&'a T> { + if let Some(value) = error.downcast_ref::() { + return Some(value); + } + let mut source = error.source(); + while let Some(value) = source { + if let Some(value) = value.downcast_ref::() { + return Some(value); + } + source = value.source(); + } + None +} + /// Creates the host's native backend with its default configuration. /// /// The native backend is selected from the compilation target. Missing native diff --git a/crates/cageforge/tests/facade.rs b/crates/cageforge/tests/facade.rs index e15c2302..8130cf16 100644 --- a/crates/cageforge/tests/facade.rs +++ b/crates/cageforge/tests/facade.rs @@ -10,6 +10,82 @@ fn portable_types_and_facade_traits_are_available_from_the_root_crate() { assert_eq!(command.program(), "cargo"); } +#[test] +fn native_diagnostic_metadata_uses_the_host_platform_code() { + let error = std::io::Error::other("native test error"); + let metadata = cageforge::native_diagnostic_metadata(&error); + #[cfg(target_os = "linux")] + assert_eq!(metadata.code(), "linux_native_error"); + #[cfg(target_os = "windows")] + assert_eq!(metadata.code(), "windows_native_error"); + #[cfg(target_os = "macos")] + assert_eq!(metadata.code(), "macos_native_error"); + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] + assert_eq!(metadata.code(), "native_error"); + assert_eq!(metadata.field(), None); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_native_diagnostic_metadata_preserves_runtime_fields() { + use std::path::PathBuf; + + let cases: &[(Box, &str, Option<&str>)] = &[ + ( + Box::new(cageforge::MacosFilesystemError::ProgramRequiresRead { + path: PathBuf::from("/opt/tool/bin/runner"), + }), + "macos_program_requires_read", + Some("command.program"), + ), + ( + Box::new( + cageforge::MacosFilesystemError::ProgramRequiresExecutableRoot { + path: PathBuf::from("/opt/tool/bin/runner"), + }, + ), + "macos_program_requires_executable_root", + Some("runtime.executable_roots"), + ), + ( + Box::new(cageforge::MacosFilesystemError::ExecutableRootMissing { + path: PathBuf::from("/opt/tool/runtime"), + }), + "macos_invalid_executable_root", + Some("runtime.executable_roots"), + ), + ( + Box::new( + cageforge::MacosFilesystemError::ExecutableRootNotDirectory { + path: PathBuf::from("/opt/tool/runtime"), + }, + ), + "macos_invalid_executable_root", + Some("runtime.executable_roots"), + ), + ( + Box::new(cageforge::MacosFilesystemError::ExecutableRootNotReadable { + path: PathBuf::from("/opt/tool/runtime"), + }), + "macos_invalid_executable_root", + Some("runtime.executable_roots"), + ), + ( + Box::new(cageforge::MacosFilesystemError::InvalidExecutableRoot { + path: PathBuf::from("relative/runtime"), + }), + "macos_invalid_executable_root", + Some("runtime.executable_roots"), + ), + ]; + + for (error, code, field) in cases { + let metadata = cageforge::native_diagnostic_metadata(error.as_ref()); + assert_eq!(metadata.code(), *code); + assert_eq!(metadata.field(), *field); + } +} + #[cfg(feature = "network-runtime")] #[test] fn network_runtime_public_api_is_available_from_the_root_crate() { diff --git a/specs/0026-structured-configuration-diagnostics.md b/specs/0026-structured-configuration-diagnostics.md index d7a6c45c..a7c4c2d4 100644 --- a/specs/0026-structured-configuration-diagnostics.md +++ b/specs/0026-structured-configuration-diagnostics.md @@ -46,19 +46,22 @@ original typed error for programmatic inspection. ## Foreign bindings -Python raises `CageforgeConfigurationError` with stable attributes: -`code`, `config_path`, `profile`, `platform`, `field`, `line`, and `column`. -Java raises `CageforgeConfigurationException` with the equivalent properties. -Bindings must preserve the error category and nested native cause, never let a -Rust panic cross FFI, and never use raw stderr or a sentinel success value as -the public error protocol. +Python raises `CageforgeConfigurationError` for config failures and +`CageforgeLaunchError` for source-aware native launch failures. Java raises +`CageforgeConfigurationException` and `CageforgeLaunchException` respectively. +Both pairs expose the stable attributes `code`, `config_path`, `profile`, +`platform`, `field`, `line`, `column`, and `command` where available. +Bindings must preserve the error category and nested native cause, use the same +platform-specific native diagnostic codes and logical fields as the CLI, never +let a Rust panic cross FFI, and never use raw stderr or a sentinel success value +as the public error protocol. ## Verification -Rust tests cover typed variants, source context, platform overlays, and native -preflight failures. CLI snapshots cover generic native failures, missing -filesystem read access, and missing executable-root declarations, including +Rust tests cover typed variants, shared native-code classification, source +context, platform overlays, and native preflight failures. CLI snapshots cover +generic Linux/Windows failures and every macOS runtime/read variant, including the platform, command, profile, field, and source location. Python and Java -tests verify the stable exception category and metadata. Native jobs remain -responsible for executing the backend-specific checks on their actual -operating system. +tests verify the stable exception category and metadata; their launch adapters +reuse the same native-code classifier. Native jobs remain responsible for +executing the backend-specific checks on their actual operating system. From 4d8f23810d95aa90740070967152efeddda257d4 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 21 Sep 2026 13:29:48 +0500 Subject: [PATCH 12/13] refactor(diagnostics): delegate metadata to native backends Co-authored-by: codex --- crates/cageforge-backend-api/README.md | 7 ++ .../cageforge-backend-api/src/diagnostic.rs | 43 +++++++++ crates/cageforge-backend-api/src/lib.rs | 2 + crates/cageforge-linux/src/diagnostic.rs | 18 ++++ crates/cageforge-linux/src/lib.rs | 1 + crates/cageforge-macos/src/diagnostic.rs | 39 ++++++++ crates/cageforge-macos/src/lib.rs | 1 + crates/cageforge-windows/src/diagnostic.rs | 18 ++++ crates/cageforge-windows/src/lib.rs | 1 + crates/cageforge/src/native.rs | 94 +++++-------------- crates/cageforge/tests/facade.rs | 9 ++ ...26-structured-configuration-diagnostics.md | 9 ++ 12 files changed, 173 insertions(+), 69 deletions(-) create mode 100644 crates/cageforge-backend-api/src/diagnostic.rs create mode 100644 crates/cageforge-linux/src/diagnostic.rs create mode 100644 crates/cageforge-macos/src/diagnostic.rs create mode 100644 crates/cageforge-windows/src/diagnostic.rs diff --git a/crates/cageforge-backend-api/README.md b/crates/cageforge-backend-api/README.md index 050ed932..e2509d7a 100644 --- a/crates/cageforge-backend-api/README.md +++ b/crates/cageforge-backend-api/README.md @@ -162,6 +162,13 @@ The native backend owns: - platform-specific core environment selection; and - process launch, stdio, timeout, cancellation, and lifecycle handling. +The native backend also owns classification of its typed launch errors. The +shared `BackendDiagnosticMetadata` and `BackendDiagnostic` contract is defined +here; each target backend supplies its own mapping, while the `cageforge` +facade only dispatches to the backend selected by `target_os`. This metadata +helps adapters present stable categories without replacing the original typed +error. + `cageforge` provides the ergonomic library API and target-specific backend selection that connect this contract to a concrete execution flow. diff --git a/crates/cageforge-backend-api/src/diagnostic.rs b/crates/cageforge-backend-api/src/diagnostic.rs new file mode 100644 index 00000000..2be77577 --- /dev/null +++ b/crates/cageforge-backend-api/src/diagnostic.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared metadata for native backend diagnostics. + +/// Stable presentation metadata for one native backend failure. +/// +/// The metadata is deliberately smaller than the backend's error enum. The +/// native backend remains the owner of the typed error and its platform +/// details; this value gives adapters a stable code and, when applicable, the +/// portable configuration field that caused the failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackendDiagnosticMetadata { + code: &'static str, + field: Option<&'static str>, +} + +impl BackendDiagnosticMetadata { + /// Creates metadata for a backend failure. + pub const fn new(code: &'static str, field: Option<&'static str>) -> Self { + Self { code, field } + } + + /// Returns the stable diagnostic code. + pub const fn code(self) -> &'static str { + self.code + } + + /// Returns the portable configuration field associated with the failure. + pub const fn field(self) -> Option<&'static str> { + self.field + } +} + +/// Supplies stable presentation metadata for a concrete backend error. +/// +/// Implement this for the backend's public top-level error and for nested +/// error types when callers may receive them directly. The implementation is +/// responsible for preserving the backend-specific typed error; this trait is +/// only an adapter-facing classification layer. +pub trait BackendDiagnostic { + /// Classifies this error without replacing or flattening the typed error. + fn diagnostic_metadata(&self) -> BackendDiagnosticMetadata; +} diff --git a/crates/cageforge-backend-api/src/lib.rs b/crates/cageforge-backend-api/src/lib.rs index eb52549a..92e42e4b 100644 --- a/crates/cageforge-backend-api/src/lib.rs +++ b/crates/cageforge-backend-api/src/lib.rs @@ -18,6 +18,7 @@ #![deny(missing_docs)] mod capability; +mod diagnostic; mod execution; mod requirements; @@ -41,6 +42,7 @@ use cageforge_policy_compose::{ }; mod model; +pub use diagnostic::{BackendDiagnostic, BackendDiagnosticMetadata}; pub use execution::{DynSandbox, Sandbox, SandboxChild, SandboxExecutionError}; pub use model::{ diff --git a/crates/cageforge-linux/src/diagnostic.rs b/crates/cageforge-linux/src/diagnostic.rs new file mode 100644 index 00000000..1dca15bf --- /dev/null +++ b/crates/cageforge-linux/src/diagnostic.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Linux-specific classification of typed native errors. + +use cageforge_backend_api::{BackendDiagnostic, BackendDiagnosticMetadata}; + +use crate::error::LinuxBackendError; + +impl BackendDiagnostic for LinuxBackendError { + fn diagnostic_metadata(&self) -> BackendDiagnosticMetadata { + match self { + Self::UnsupportedCapability { .. } => { + BackendDiagnosticMetadata::new("linux_unsupported_capability", Some("capability")) + } + _ => BackendDiagnosticMetadata::new("linux_native_error", None), + } + } +} diff --git a/crates/cageforge-linux/src/lib.rs b/crates/cageforge-linux/src/lib.rs index b5cf330c..aab8240c 100644 --- a/crates/cageforge-linux/src/lib.rs +++ b/crates/cageforge-linux/src/lib.rs @@ -19,6 +19,7 @@ use std::process::ExitCode; mod backend; mod bwrap; mod config; +mod diagnostic; mod environment_transport; mod error; mod filesystem; diff --git a/crates/cageforge-macos/src/diagnostic.rs b/crates/cageforge-macos/src/diagnostic.rs new file mode 100644 index 00000000..96434e7e --- /dev/null +++ b/crates/cageforge-macos/src/diagnostic.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! macOS-specific classification of typed native errors. + +use cageforge_backend_api::{BackendDiagnostic, BackendDiagnosticMetadata}; + +use crate::error::{MacosBackendError, MacosFilesystemError}; + +impl BackendDiagnostic for MacosFilesystemError { + fn diagnostic_metadata(&self) -> BackendDiagnosticMetadata { + match self { + Self::ProgramRequiresRead { .. } => BackendDiagnosticMetadata::new( + "macos_program_requires_read", + Some("command.program"), + ), + Self::ProgramRequiresExecutableRoot { .. } => BackendDiagnosticMetadata::new( + "macos_program_requires_executable_root", + Some("runtime.executable_roots"), + ), + Self::ExecutableRootMissing { .. } + | Self::ExecutableRootNotDirectory { .. } + | Self::ExecutableRootNotReadable { .. } + | Self::InvalidExecutableRoot { .. } => BackendDiagnosticMetadata::new( + "macos_invalid_executable_root", + Some("runtime.executable_roots"), + ), + _ => BackendDiagnosticMetadata::new("macos_native_error", None), + } + } +} + +impl BackendDiagnostic for MacosBackendError { + fn diagnostic_metadata(&self) -> BackendDiagnosticMetadata { + match self { + Self::Filesystem(error) => error.diagnostic_metadata(), + _ => BackendDiagnosticMetadata::new("macos_native_error", None), + } + } +} diff --git a/crates/cageforge-macos/src/lib.rs b/crates/cageforge-macos/src/lib.rs index 70eb5a8d..809ccd4b 100644 --- a/crates/cageforge-macos/src/lib.rs +++ b/crates/cageforge-macos/src/lib.rs @@ -14,6 +14,7 @@ mod backend; mod config; +mod diagnostic; mod error; mod filesystem; mod network; diff --git a/crates/cageforge-windows/src/diagnostic.rs b/crates/cageforge-windows/src/diagnostic.rs new file mode 100644 index 00000000..712b7843 --- /dev/null +++ b/crates/cageforge-windows/src/diagnostic.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Windows-specific classification of typed native errors. + +use cageforge_backend_api::{BackendDiagnostic, BackendDiagnosticMetadata}; + +use crate::error::WindowsBackendError; + +impl BackendDiagnostic for WindowsBackendError { + fn diagnostic_metadata(&self) -> BackendDiagnosticMetadata { + match self { + Self::UnsupportedCapability { .. } => { + BackendDiagnosticMetadata::new("windows_unsupported_capability", Some("capability")) + } + _ => BackendDiagnosticMetadata::new("windows_native_error", None), + } + } +} diff --git a/crates/cageforge-windows/src/lib.rs b/crates/cageforge-windows/src/lib.rs index 5c58de4a..8c98fddb 100644 --- a/crates/cageforge-windows/src/lib.rs +++ b/crates/cageforge-windows/src/lib.rs @@ -18,6 +18,7 @@ mod acl_contract; mod backend; mod capability; mod config; +mod diagnostic; mod error; mod filesystem; mod firewall_contract; diff --git a/crates/cageforge/src/native.rs b/crates/cageforge/src/native.rs index d76c6184..1554eca4 100644 --- a/crates/cageforge/src/native.rs +++ b/crates/cageforge/src/native.rs @@ -4,26 +4,12 @@ use std::error::Error; +use cageforge_backend_api::{BackendDiagnostic, BackendDiagnosticMetadata}; + use crate::DynSandbox; -/// Stable source-field metadata for a native launch failure. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NativeDiagnosticMetadata { - code: &'static str, - field: Option<&'static str>, -} - -impl NativeDiagnosticMetadata { - /// Returns the stable native diagnostic code. - pub const fn code(self) -> &'static str { - self.code - } - - /// Returns the logical configuration field associated with the failure. - pub const fn field(self) -> Option<&'static str> { - self.field - } -} +/// Backwards-compatible facade name for the shared backend diagnostic type. +pub type NativeDiagnosticMetadata = BackendDiagnosticMetadata; /// Configuration of the backend selected for the compilation target. #[cfg(target_os = "linux")] @@ -55,77 +41,47 @@ pub enum NativeSandboxError { /// Classifies a typed native failure for source-aware adapters. /// -/// This function only selects stable presentation metadata. The original -/// error remains the typed source error and must be retained by the caller. +/// Target-specific classification remains in the selected native backend. +/// This facade function only dispatches to that backend; the original error +/// remains the typed source error and must be retained by the caller. pub fn native_diagnostic_metadata(error: &(dyn Error + 'static)) -> NativeDiagnosticMetadata { #[cfg(target_os = "macos")] { - if let Some(native) = find_error::(error) { - return match native { - cageforge_macos::MacosFilesystemError::ProgramRequiresRead { .. } => { - NativeDiagnosticMetadata { - code: "macos_program_requires_read", - field: Some("command.program"), - } - } - cageforge_macos::MacosFilesystemError::ProgramRequiresExecutableRoot { .. } => { - NativeDiagnosticMetadata { - code: "macos_program_requires_executable_root", - field: Some("runtime.executable_roots"), - } - } - cageforge_macos::MacosFilesystemError::ExecutableRootMissing { .. } - | cageforge_macos::MacosFilesystemError::ExecutableRootNotDirectory { .. } - | cageforge_macos::MacosFilesystemError::ExecutableRootNotReadable { .. } - | cageforge_macos::MacosFilesystemError::InvalidExecutableRoot { .. } => { - NativeDiagnosticMetadata { - code: "macos_invalid_executable_root", - field: Some("runtime.executable_roots"), - } - } - _ => NativeDiagnosticMetadata { - code: "macos_native_error", - field: None, - }, - }; - } - NativeDiagnosticMetadata { - code: "macos_native_error", - field: None, - } + find_error_source::(error) + .map(BackendDiagnostic::diagnostic_metadata) + .or_else(|| { + find_error_source::(error) + .map(BackendDiagnostic::diagnostic_metadata) + }) + .unwrap_or_else(|| BackendDiagnosticMetadata::new("macos_native_error", None)) } #[cfg(target_os = "linux")] { - let _ = error; - NativeDiagnosticMetadata { - code: "linux_native_error", - field: None, - } + find_error_source::(error) + .map(BackendDiagnostic::diagnostic_metadata) + .unwrap_or_else(|| BackendDiagnosticMetadata::new("linux_native_error", None)) } + #[cfg(target_os = "windows")] { - let _ = error; - NativeDiagnosticMetadata { - code: "windows_native_error", - field: None, - } + find_error_source::(error) + .map(BackendDiagnostic::diagnostic_metadata) + .unwrap_or_else(|| BackendDiagnosticMetadata::new("windows_native_error", None)) } + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] { let _ = error; - NativeDiagnosticMetadata { - code: "native_error", - field: None, - } + BackendDiagnosticMetadata::new("native_error", None) } } -#[cfg(target_os = "macos")] -fn find_error<'a, T: Error + 'static>(error: &'a (dyn Error + 'static)) -> Option<&'a T> { +fn find_error_source<'a, T: Error + 'static>(error: &'a (dyn Error + 'static)) -> Option<&'a T> { if let Some(value) = error.downcast_ref::() { return Some(value); } + let mut source = error.source(); while let Some(value) = source { if let Some(value) = value.downcast_ref::() { diff --git a/crates/cageforge/tests/facade.rs b/crates/cageforge/tests/facade.rs index 8130cf16..48f6e976 100644 --- a/crates/cageforge/tests/facade.rs +++ b/crates/cageforge/tests/facade.rs @@ -28,8 +28,17 @@ fn native_diagnostic_metadata_uses_the_host_platform_code() { #[cfg(target_os = "macos")] #[test] fn macos_native_diagnostic_metadata_preserves_runtime_fields() { + use cageforge::BackendDiagnostic; use std::path::PathBuf; + let direct = cageforge::MacosFilesystemError::ProgramRequiresRead { + path: PathBuf::from("/opt/tool/bin/runner"), + }; + assert_eq!( + direct.diagnostic_metadata().code(), + "macos_program_requires_read" + ); + let cases: &[(Box, &str, Option<&str>)] = &[ ( Box::new(cageforge::MacosFilesystemError::ProgramRequiresRead { diff --git a/specs/0026-structured-configuration-diagnostics.md b/specs/0026-structured-configuration-diagnostics.md index a7c4c2d4..c8aa0465 100644 --- a/specs/0026-structured-configuration-diagnostics.md +++ b/specs/0026-structured-configuration-diagnostics.md @@ -44,6 +44,15 @@ duplicate the Seatbelt baseline. The CLI and host-language adapters may wrap the typed native cause in a source-aware diagnostic, but must retain the original typed error for programmatic inspection. +The portable `cageforge-backend-api` crate defines the minimal +`BackendDiagnostic` trait and `BackendDiagnosticMetadata` value used by +adapters. Each native backend implements that trait for its own public typed +errors and owns the mapping of platform-specific variants to stable codes and +portable fields. A caller using a native backend crate directly can inspect +the complete OS-specific error and optionally call the trait method; the +facade adds only source-chain dispatch for callers whose errors are already +wrapped behind `dyn Error`. + ## Foreign bindings Python raises `CageforgeConfigurationError` for config failures and From 55968411d334ee5ae9597cdc053833af4ad4f418 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 21 Sep 2026 13:45:54 +0500 Subject: [PATCH 13/13] refactor(diagnostics): unify source-aware runtime diagnostics Co-authored-by: codex --- crates/bindings/cageforge-java/src/error.rs | 11 +++----- crates/bindings/cageforge-python/src/lib.rs | 11 +++----- crates/cageforge-cli/src/execution.rs | 8 +----- crates/cageforge/README.md | 7 +++++ crates/cageforge/src/lib.rs | 2 ++ crates/cageforge/src/native.rs | 26 +++++++++++++++++++ crates/cageforge/tests/facade.rs | 20 ++++++++++++++ ...26-structured-configuration-diagnostics.md | 14 +++++++--- 8 files changed, 73 insertions(+), 26 deletions(-) diff --git a/crates/bindings/cageforge-java/src/error.rs b/crates/bindings/cageforge-java/src/error.rs index 7a82af97..473b6391 100644 --- a/crates/bindings/cageforge-java/src/error.rs +++ b/crates/bindings/cageforge-java/src/error.rs @@ -127,14 +127,9 @@ impl BindingDiagnostic { command: &cageforge::CommandRequest, error: &(dyn std::error::Error + 'static), ) -> (Self, String) { - let source = source.clone().with_command(display_command(command)); - let metadata = cageforge::native_diagnostic_metadata(error); - let diagnostic = cageforge::ConfigDiagnostic::for_runtime_failure( - &source, - metadata.code(), - error.to_string(), - metadata.field(), - ); + let command = display_command(command); + let diagnostic = + cageforge::config_diagnostic_for_runtime_failure(source, Some(&command), error); let location = diagnostic.location(); ( Self { diff --git a/crates/bindings/cageforge-python/src/lib.rs b/crates/bindings/cageforge-python/src/lib.rs index 54a715f9..6148d330 100644 --- a/crates/bindings/cageforge-python/src/lib.rs +++ b/crates/bindings/cageforge-python/src/lib.rs @@ -396,14 +396,9 @@ fn launch_diagnostic_error( command: &cageforge::CommandRequest, error: &(dyn std::error::Error + 'static), ) -> PyErr { - let source = source.clone().with_command(display_command(command)); - let metadata = cageforge::native_diagnostic_metadata(error); - let diagnostic = cageforge::ConfigDiagnostic::for_runtime_failure( - &source, - metadata.code(), - error.to_string(), - metadata.field(), - ); + let command = display_command(command); + let diagnostic = + cageforge::config_diagnostic_for_runtime_failure(source, Some(&command), error); let exception = CageforgeLaunchError::new_err(diagnostic.render_human()); Python::attach(|py| { let value = exception.value(py); diff --git a/crates/cageforge-cli/src/execution.rs b/crates/cageforge-cli/src/execution.rs index 90807183..41d61110 100644 --- a/crates/cageforge-cli/src/execution.rs +++ b/crates/cageforge-cli/src/execution.rs @@ -622,13 +622,7 @@ fn execute_native_inner(invocation: Invocation) -> Result { any(target_os = "linux", target_os = "windows", target_os = "macos",) ))] fn enrich_native_error(source: cageforge::ProfileSourceContext, error: CliError) -> CliError { - let metadata = cageforge::native_diagnostic_metadata(&error); - let diagnostic = cageforge::ConfigDiagnostic::for_runtime_failure( - &source, - metadata.code(), - error.to_string(), - metadata.field(), - ); + let diagnostic = cageforge::config_diagnostic_for_runtime_failure(&source, None, &error); CliError::NativeDiagnostic { diagnostic: Box::new(diagnostic), source: Box::new(error), diff --git a/crates/cageforge/README.md b/crates/cageforge/README.md index bebc8702..0e1146d9 100644 --- a/crates/cageforge/README.md +++ b/crates/cageforge/README.md @@ -320,6 +320,13 @@ types. waiting, and termination. Native child types and errors remain available when platform-specific behavior or diagnostics are needed. +With the `config` feature, pass a resolved profile's `source_context()` and +the original native error to +`config_diagnostic_for_runtime_failure`. The facade then combines the +backend-owned error code and field with the TOML path, profile, platform, and +line/column metadata. The returned diagnostic is presentation data; the +original typed error remains available for programmatic handling. + The public API is synchronous. Internal network gateways may use helper threads or asynchronous tasks. An async application only needs to run the blocking process operations in its blocking-task facility. diff --git a/crates/cageforge/src/lib.rs b/crates/cageforge/src/lib.rs index 561f4e36..4405eadb 100644 --- a/crates/cageforge/src/lib.rs +++ b/crates/cageforge/src/lib.rs @@ -21,6 +21,8 @@ mod native; mod preflight; +#[cfg(feature = "config")] +pub use native::config_diagnostic_for_runtime_failure; pub use native::{ NativeDiagnosticMetadata, NativeSandboxError, native_diagnostic_metadata, native_sandbox, }; diff --git a/crates/cageforge/src/native.rs b/crates/cageforge/src/native.rs index 1554eca4..07a3cfc4 100644 --- a/crates/cageforge/src/native.rs +++ b/crates/cageforge/src/native.rs @@ -77,6 +77,32 @@ pub fn native_diagnostic_metadata(error: &(dyn Error + 'static)) -> NativeDiagno } } +/// Builds the common source-aware diagnostic for a native launch failure. +/// +/// The selected backend supplies the stable code and logical field through +/// [`BackendDiagnostic`]. The configuration layer supplies the profile, +/// platform, TOML path, and source location. The original error remains the +/// caller's typed source error; this helper only combines presentation +/// metadata for an application using the `config` feature. +#[cfg(feature = "config")] +pub fn config_diagnostic_for_runtime_failure( + source: &cageforge_config::ProfileSourceContext, + command: Option<&str>, + error: &(dyn Error + 'static), +) -> cageforge_config::ConfigDiagnostic { + let source = command.map_or_else( + || source.clone(), + |command| source.clone().with_command(command.to_owned()), + ); + let metadata = native_diagnostic_metadata(error); + cageforge_config::ConfigDiagnostic::for_runtime_failure( + &source, + metadata.code(), + error.to_string(), + metadata.field(), + ) +} + fn find_error_source<'a, T: Error + 'static>(error: &'a (dyn Error + 'static)) -> Option<&'a T> { if let Some(value) = error.downcast_ref::() { return Some(value); diff --git a/crates/cageforge/tests/facade.rs b/crates/cageforge/tests/facade.rs index 48f6e976..bba6a1a8 100644 --- a/crates/cageforge/tests/facade.rs +++ b/crates/cageforge/tests/facade.rs @@ -25,6 +25,26 @@ fn native_diagnostic_metadata_uses_the_host_platform_code() { assert_eq!(metadata.field(), None); } +#[cfg(feature = "config")] +#[test] +fn config_diagnostic_helper_combines_backend_and_toml_context() { + let config = cageforge::Config::from_toml("default_profile = \"safe\"\n\n[profiles.safe]\n") + .expect("valid profile"); + let profile = config.resolve("safe").expect("profile resolves"); + let error = std::io::Error::other("native test error"); + + let diagnostic = cageforge::config_diagnostic_for_runtime_failure( + profile.source_context(), + Some("/bin/tool --worker"), + &error, + ); + + assert_eq!(diagnostic.profile(), Some("safe")); + assert_eq!(diagnostic.command(), Some("/bin/tool --worker")); + assert!(diagnostic.location().is_some()); + assert_eq!(diagnostic.message(), "native test error"); +} + #[cfg(target_os = "macos")] #[test] fn macos_native_diagnostic_metadata_preserves_runtime_fields() { diff --git a/specs/0026-structured-configuration-diagnostics.md b/specs/0026-structured-configuration-diagnostics.md index c8aa0465..b8cccfa5 100644 --- a/specs/0026-structured-configuration-diagnostics.md +++ b/specs/0026-structured-configuration-diagnostics.md @@ -33,6 +33,13 @@ semantic policy value. Native adapters use that metadata to attach the selected profile, effective command, logical field, and TOML line/column to a backend failure without making the backend parse TOML. +When the facade is built with its `config` feature, Rust applications use +`cageforge::config_diagnostic_for_runtime_failure` for the same combination. +The helper accepts the retained `ProfileSourceContext`, an optional effective +command, and the original typed native error. It does not replace that error; +it only applies the backend-owned diagnostic metadata to the config-owned +source context. + ## Native boundary The selected backend performs native capability checks after composition and @@ -40,9 +47,10 @@ before process creation. For macOS, Seatbelt owns the fixed executable baseline and checks absolute custom programs against effective read access and explicit `runtime.executable_roots`. It returns typed errors such as `ProgramRequiresRead` and `ProgramRequiresExecutableRoot`; config does not -duplicate the Seatbelt baseline. The CLI and host-language adapters may wrap -the typed native cause in a source-aware diagnostic, but must retain the -original typed error for programmatic inspection. +duplicate the Seatbelt baseline. The CLI, Rust facade helper, and +host-language adapters may wrap the typed native cause in a source-aware +diagnostic, but must retain the original typed error for programmatic +inspection. The portable `cageforge-backend-api` crate defines the minimal `BackendDiagnostic` trait and `BackendDiagnosticMetadata` value used by