From 8acc61895b79b2ffdafac83847b26334d1379bf3 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Wed, 26 Aug 2026 17:22:12 +0300 Subject: [PATCH 01/19] # manifest: add optional build provenance declaration --- Cargo.lock | 4 +- crates/mcp-stdio-adapter/src/main.rs | 1 + crates/subc-client-rs/Cargo.toml | 2 +- crates/subc-client-rs/examples/echo-module.rs | 1 + crates/subc-client-rs/src/lib.rs | 89 ++++++++++++++- crates/subc-client-rs/tests/real_daemon.rs | 1 + crates/subc-core/Cargo.toml | 2 +- crates/subc-core/build.rs | 4 +- crates/subc-core/src/bench_harness.rs | 1 + crates/subc-core/src/bin/ck.rs | 2 +- crates/subc-core/src/bin/fake-aft-stub.rs | 1 + crates/subc-core/src/control.rs | 5 +- crates/subc-core/src/registry.rs | 1 + crates/subc-core/tests/catalog_update.rs | 33 ++++-- crates/subc-core/tests/daemon_config.rs | 1 + crates/subc-core/tests/forwarding.rs | 1 + crates/subc-core/tests/reverse_request.rs | 1 + crates/subc-mcp/src/main.rs | 23 ++++ crates/subc-mcp/tests/manifest.rs | 1 + crates/subc-mcp/tests/phase1_integration.rs | 1 + crates/subc-protocol/src/lib.rs | 2 + crates/subc-protocol/src/manifest.rs | 18 +++ .../module_hello_body_with_provenance.json | 60 ++++++++++ crates/subc-protocol/tests/golden_json.rs | 108 +++++++++++++++++- 24 files changed, 338 insertions(+), 25 deletions(-) create mode 100644 crates/subc-protocol/tests/golden/module_hello_body_with_provenance.json diff --git a/Cargo.lock b/Cargo.lock index da3b2bdd..84d0e674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,7 +872,7 @@ dependencies = [ [[package]] name = "subc-client-rs" -version = "0.7.3" +version = "0.8.0" dependencies = [ "async-trait", "serde", @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "subc-core" -version = "0.7.0" +version = "0.8.0" dependencies = [ "cortexkit-paths", "fs4", diff --git a/crates/mcp-stdio-adapter/src/main.rs b/crates/mcp-stdio-adapter/src/main.rs index 390d98bb..8cc8623a 100644 --- a/crates/mcp-stdio-adapter/src/main.rs +++ b/crates/mcp-stdio-adapter/src/main.rs @@ -146,6 +146,7 @@ fn manifest() -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-client-rs/Cargo.toml b/crates/subc-client-rs/Cargo.toml index efbe1233..cc4be1fd 100644 --- a/crates/subc-client-rs/Cargo.toml +++ b/crates/subc-client-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "subc-client-rs" -version = "0.7.3" +version = "0.8.0" edition = "2021" publish = true description = "Shared serve + consume client for Rust subc modules." diff --git a/crates/subc-client-rs/examples/echo-module.rs b/crates/subc-client-rs/examples/echo-module.rs index 123f98d9..00fc4271 100644 --- a/crates/subc-client-rs/examples/echo-module.rs +++ b/crates/subc-client-rs/examples/echo-module.rs @@ -191,5 +191,6 @@ fn manifest(module_id: &str) -> subc_protocol::manifest::ModuleManifest { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-client-rs/src/lib.rs b/crates/subc-client-rs/src/lib.rs index 1931fe00..918670cf 100644 --- a/crates/subc-client-rs/src/lib.rs +++ b/crates/subc-client-rs/src/lib.rs @@ -44,12 +44,95 @@ use subc_protocol::{ }; pub use subc_protocol::{ manifest::{ - CapabilityDeclarations, CapabilityNeed, CapabilityRequirement, ExecutionMode, ProviderRole, - Tool, + CapabilityDeclarations, CapabilityNeed, CapabilityRequirement, ExecutionMode, + ManifestProvenance, ProviderRole, Tool, }, session::{HealthReport, HealthStatus}, - AdmissionClass, + AdmissionClass, SUBC_PROTOCOL_CRATE_VERSION, }; + +pub fn build_provenance( + build_commit: Option<&str>, + build_lock_digest: Option<&str>, + store_schema_version: Option<&str>, +) -> ManifestProvenance { + ManifestProvenance { + build_commit: normalize_provenance_fact(build_commit), + build_lock_digest: normalize_provenance_fact(build_lock_digest), + wire_crate_version: Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()), + store_schema_version: normalize_provenance_fact(store_schema_version), + } +} + +fn normalize_provenance_fact(value: Option<&str>) -> Option { + let value = value?.trim(); + (!value.is_empty() && value != "unavailable").then(|| value.to_string()) +} + +#[test] +fn build_provenance_normalizes_clean_build_facts() { + let provenance = build_provenance( + Some(" 0123456789abcdef0123456789abcdef01234567 "), + Some(" lock-digest "), + Some(" schema-v3 "), + ); + + assert_eq!( + provenance, + ManifestProvenance { + build_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_lock_digest: Some("lock-digest".to_string()), + wire_crate_version: Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()), + store_schema_version: Some("schema-v3".to_string()), + } + ); +} + +#[test] +fn build_provenance_preserves_a_dirty_revision_verbatim() { + let provenance = build_provenance( + Some("0123456789abcdef0123456789abcdef01234567-dirty"), + Some("lock-digest"), + None, + ); + + assert_eq!( + provenance.build_commit, + Some("0123456789abcdef0123456789abcdef01234567-dirty".to_string()) + ); + assert_eq!( + provenance.build_lock_digest, + Some("lock-digest".to_string()) + ); +} + +#[test] +fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() { + let provenance = build_provenance(Some("unavailable"), Some("lock-digest"), None); + + assert_eq!(provenance.build_commit, None); + assert_eq!( + provenance.build_lock_digest, + Some("lock-digest".to_string()) + ); + assert_eq!( + provenance.wire_crate_version, + Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()) + ); +} + +#[test] +fn build_provenance_omits_fully_unavailable_inputs() { + let provenance = build_provenance(None, Some(" unavailable "), Some(" ")); + + assert_eq!(provenance.build_commit, None); + assert_eq!(provenance.build_lock_digest, None); + assert_eq!(provenance.store_schema_version, None); + assert_eq!( + provenance.wire_crate_version, + Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()) + ); +} use subc_transport::{ authenticate_client, connection_file, read_frame, write_frame, AuthError, ConnectionFileError, FrameIoError, diff --git a/crates/subc-client-rs/tests/real_daemon.rs b/crates/subc-client-rs/tests/real_daemon.rs index fe8cb85e..8fb70e6f 100644 --- a/crates/subc-client-rs/tests/real_daemon.rs +++ b/crates/subc-client-rs/tests/real_daemon.rs @@ -1606,6 +1606,7 @@ fn inline_module_manifest(module_id: &str, tool_names: &[&str]) -> ModuleManifes }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-core/Cargo.toml b/crates/subc-core/Cargo.toml index 206fd0c4..c1115c96 100644 --- a/crates/subc-core/Cargo.toml +++ b/crates/subc-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "subc-core" -version = "0.7.0" +version = "0.8.0" edition = "2021" publish = false description = "subc daemon core: loopback TCP transport and opaque-byte splice router." diff --git a/crates/subc-core/build.rs b/crates/subc-core/build.rs index c1da46d2..7310b04f 100644 --- a/crates/subc-core/build.rs +++ b/crates/subc-core/build.rs @@ -24,11 +24,11 @@ fn main() { // would compare EQUAL between two broken builds, which reads as a verified // match precisely when nothing was verified. println!( - "cargo:rustc-env=SUBC_BUILD_GIT_SHA={}", + "cargo:rustc-env=CK_BUILD_REV={}", git_head_sha().unwrap_or_else(|| "unavailable".to_string()) ); println!( - "cargo:rustc-env=SUBC_BUILD_LOCK_DIGEST={}", + "cargo:rustc-env=CK_BUILD_LOCK_DIGEST={}", lock_digest().unwrap_or_else(|| "unavailable".to_string()) ); } diff --git a/crates/subc-core/src/bench_harness.rs b/crates/subc-core/src/bench_harness.rs index 4e271069..c55556ac 100644 --- a/crates/subc-core/src/bench_harness.rs +++ b/crates/subc-core/src/bench_harness.rs @@ -70,6 +70,7 @@ pub fn bench_tool_provider_manifest(module_id: &str) -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-core/src/bin/ck.rs b/crates/subc-core/src/bin/ck.rs index e02abc7c..eccb218e 100644 --- a/crates/subc-core/src/bin/ck.rs +++ b/crates/subc-core/src/bin/ck.rs @@ -2020,7 +2020,7 @@ async fn daemon(client: &mut CkClient, json_output: bool) -> Result<(), CkError> /// nothing, and that prints as unverifiable rather than as a match -- absence /// of the check must not read as the check passing. fn print_build_skew(describe: &Value) { - let cli_sha = env!("SUBC_BUILD_GIT_SHA"); + let cli_sha = env!("CK_BUILD_REV"); let daemon_sha = describe.get("build_git_sha").and_then(Value::as_str); match daemon_sha { None => println!( diff --git a/crates/subc-core/src/bin/fake-aft-stub.rs b/crates/subc-core/src/bin/fake-aft-stub.rs index f9410212..7d55512e 100644 --- a/crates/subc-core/src/bin/fake-aft-stub.rs +++ b/crates/subc-core/src/bin/fake-aft-stub.rs @@ -1302,6 +1302,7 @@ fn manifest( }, }, capabilities, + provenance: None, } } diff --git a/crates/subc-core/src/control.rs b/crates/subc-core/src/control.rs index eb9d5948..246bce3f 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -1356,8 +1356,8 @@ impl ControlHandler { capabilities: self.subc_capabilities.as_ref().to_vec(), connected_clients: self.connected_clients.count(), counters: Some(counters), - build_git_sha: Some(env!("SUBC_BUILD_GIT_SHA").to_string()), - build_lock_digest: Some(env!("SUBC_BUILD_LOCK_DIGEST").to_string()), + build_git_sha: Some(env!("CK_BUILD_REV").to_string()), + build_lock_digest: Some(env!("CK_BUILD_LOCK_DIGEST").to_string()), capability_requirements: self.capability_requirement_statuses(), }; Ok(vec![control_response_body_frame( @@ -4033,6 +4033,7 @@ mod tests { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-core/src/registry.rs b/crates/subc-core/src/registry.rs index cc6586f3..e1ce4e55 100644 --- a/crates/subc-core/src/registry.rs +++ b/crates/subc-core/src/registry.rs @@ -299,6 +299,7 @@ mod path_hazard_tests { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-core/tests/catalog_update.rs b/crates/subc-core/tests/catalog_update.rs index 2629ecbb..b7a62329 100644 --- a/crates/subc-core/tests/catalog_update.rs +++ b/crates/subc-core/tests/catalog_update.rs @@ -12,8 +12,8 @@ use subc_control::{CatalogEntry, ClientControlRequest, ClientControlResponse}; use subc_core::{read_frame, write_frame, Frame}; use subc_protocol::{ manifest::{ - Bindings, Concurrency, ExecutionMode, IdentityBinding, IdentityScope, ModuleManifest, - ProviderRole, StorageBinding, StorageKind, StorageScope, Tool, TrustTier, + Bindings, Concurrency, ExecutionMode, IdentityBinding, IdentityScope, ManifestProvenance, + ModuleManifest, ProviderRole, StorageBinding, StorageKind, StorageScope, Tool, TrustTier, }, session::{ ModuleControlRequest, ModuleControlRequestFromModule, ModuleControlResponse, @@ -72,13 +72,16 @@ async fn catalog_update_refreshes_catalog_without_disrupting_bound_routes() { let server = TestServer::start().await; let module_id = "catalog-update-provider"; let mut module = connect_endpoint(&server, "module").await; - let hello_ack = register_module( - &server, - &mut module, - tool_provider_manifest(module_id, &["a", "b"], Concurrency::ModuleManaged), - 101, - ) - .await; + let provenance = ManifestProvenance { + build_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_lock_digest: Some("lock-digest".to_string()), + wire_crate_version: Some("0.13.0".to_string()), + store_schema_version: Some("3".to_string()), + }; + let mut initial_manifest = + tool_provider_manifest(module_id, &["a", "b"], Concurrency::ModuleManaged); + initial_manifest.provenance = Some(provenance.clone()); + let hello_ack = register_module(&server, &mut module, initial_manifest, 101).await; assert!(hello_ack.subc_ops.contains(&"catalog.update".to_string())); let (initial_generation, initial_modules) = catalog_list(&server, Some(module_id), 201).await; @@ -130,6 +133,17 @@ async fn catalog_update_refreshes_catalog_without_disrupting_bound_routes() { let (updated_generation, updated_modules) = catalog_list(&server, Some(module_id), 202).await; assert!(updated_generation > initial_generation); assert_tool_names(&updated_modules[0], &["a", "c"]); + assert_eq!( + server + .registry + .get_module(module_id) + .expect("registry query succeeds") + .expect("catalog.update keeps the registration") + .manifest + .provenance, + Some(provenance), + "catalog.update must preserve HELLO provenance inherited by its struct update" + ); assert_eq!(server.forwarding.active_binding_count().unwrap(), 1); assert!(server .forwarding @@ -433,6 +447,7 @@ fn supervision_only_manifest(module_id: &str) -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-core/tests/daemon_config.rs b/crates/subc-core/tests/daemon_config.rs index 2e209e5b..f2b93ba6 100644 --- a/crates/subc-core/tests/daemon_config.rs +++ b/crates/subc-core/tests/daemon_config.rs @@ -1513,6 +1513,7 @@ fn untrusted_hello_frame(module_id: &str, corr: u64) -> Frame { }, }, capabilities: None, + provenance: None, }; let body = serde_json::to_vec(&ModuleHelloBody { manifest, diff --git a/crates/subc-core/tests/forwarding.rs b/crates/subc-core/tests/forwarding.rs index 541e41ab..cf77bbc0 100644 --- a/crates/subc-core/tests/forwarding.rs +++ b/crates/subc-core/tests/forwarding.rs @@ -5387,6 +5387,7 @@ fn consumer_manifest(module_id: &str) -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-core/tests/reverse_request.rs b/crates/subc-core/tests/reverse_request.rs index 791e06cd..ddf9fa11 100644 --- a/crates/subc-core/tests/reverse_request.rs +++ b/crates/subc-core/tests/reverse_request.rs @@ -1041,6 +1041,7 @@ fn tool_provider_manifest(module_id: &str, concurrency: Concurrency) -> ModuleMa }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-mcp/src/main.rs b/crates/subc-mcp/src/main.rs index 0b6b1271..3a34c4d5 100644 --- a/crates/subc-mcp/src/main.rs +++ b/crates/subc-mcp/src/main.rs @@ -1947,6 +1947,28 @@ fn manifest_output_always_includes_an_empty_runtime_computed_array() { assert_eq!(value["runtime_computed"], serde_json::json!([])); } +#[cfg(test)] +#[test] +fn manifest_output_keeps_provenance_in_the_static_manifest_object() { + let mut manifest = supervision_manifest(MANIFEST_MODULE_ID.to_string()); + manifest.provenance = Some(subc_protocol::manifest::ManifestProvenance { + build_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_lock_digest: None, + wire_crate_version: Some("0.13.0".to_string()), + store_schema_version: None, + }); + + let value = manifest_json(manifest); + assert_eq!( + value["provenance"], + serde_json::json!({ + "build_commit": "0123456789abcdef0123456789abcdef01234567", + "wire_crate_version": "0.13.0" + }) + ); + assert_eq!(value["runtime_computed"], serde_json::json!([])); +} + fn supervision_manifest(module_id: String) -> ModuleManifest { ModuleManifest { module_id, @@ -1957,6 +1979,7 @@ fn supervision_manifest(module_id: String) -> ModuleManifest { consumes: vec![ConsumerRole::ToolClient { of: Vec::new() }], bindings: supervision_bindings(), capabilities: None, + provenance: None, } } diff --git a/crates/subc-mcp/tests/manifest.rs b/crates/subc-mcp/tests/manifest.rs index 61afa8d0..3798e676 100644 --- a/crates/subc-mcp/tests/manifest.rs +++ b/crates/subc-mcp/tests/manifest.rs @@ -16,5 +16,6 @@ fn manifest_is_emitted_offline_without_module_setup() { let manifest: serde_json::Value = serde_json::from_slice(&output.stdout).expect("manifest JSON"); assert_eq!(manifest["runtime_computed"], serde_json::json!([])); + assert!(manifest.get("provenance").is_none()); assert_eq!(manifest["module_id"], "ck-subc-mcp"); } diff --git a/crates/subc-mcp/tests/phase1_integration.rs b/crates/subc-mcp/tests/phase1_integration.rs index d79dc3f6..138a00ee 100644 --- a/crates/subc-mcp/tests/phase1_integration.rs +++ b/crates/subc-mcp/tests/phase1_integration.rs @@ -4610,6 +4610,7 @@ fn raw_provider_manifest(module_id: &str, tool_name: &str) -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-protocol/src/lib.rs b/crates/subc-protocol/src/lib.rs index d3853712..16f4720c 100644 --- a/crates/subc-protocol/src/lib.rs +++ b/crates/subc-protocol/src/lib.rs @@ -122,6 +122,8 @@ pub enum RouteTarget { /// Envelope protocol version this build speaks. pub const PROTOCOL_VERSION: u8 = 2; +pub const SUBC_PROTOCOL_CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); + /// Oldest envelope protocol version this build accepts. pub const MIN_SUPPORTED_VERSION: u8 = 2; diff --git a/crates/subc-protocol/src/manifest.rs b/crates/subc-protocol/src/manifest.rs index 9c5b074a..9dae2559 100644 --- a/crates/subc-protocol/src/manifest.rs +++ b/crates/subc-protocol/src/manifest.rs @@ -30,6 +30,8 @@ pub struct ModuleManifest { /// the daemon validates before accepting a HELLO. #[serde(default, skip_serializing_if = "Option::is_none")] pub capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, } #[derive(Deserialize)] @@ -43,6 +45,8 @@ struct ModuleManifestWire { bindings: Bindings, #[serde(default)] capabilities: Option, + #[serde(default)] + provenance: Option, // `runtime_computed` belongs to --manifest output rather than the retained // manifest model. Deserialize it only long enough to enforce that capability // declarations cannot be omitted as runtime-varying data. @@ -67,6 +71,7 @@ impl<'de> Deserialize<'de> for ModuleManifest { consumes: wire.consumes, bindings: wire.bindings, capabilities: wire.capabilities, + provenance: wire.provenance, }; manifest .validate_capability_grammar() @@ -87,6 +92,18 @@ pub struct CapabilityDeclarations { pub must_never_reach: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ManifestProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_lock_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wire_crate_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub store_schema_version: Option, +} + /// One capability a module consumes and whether its absence is tolerated. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -769,6 +786,7 @@ mod tests { }, }, capabilities: None, + provenance: None, } } diff --git a/crates/subc-protocol/tests/golden/module_hello_body_with_provenance.json b/crates/subc-protocol/tests/golden/module_hello_body_with_provenance.json new file mode 100644 index 00000000..d3f86e90 --- /dev/null +++ b/crates/subc-protocol/tests/golden/module_hello_body_with_provenance.json @@ -0,0 +1,60 @@ +{ + "control_ops": [ + "route.bind", + "route.status" + ], + "manifest": { + "bindings": { + "identity": { + "optional": [ + "session" + ], + "requires": [ + "project" + ] + }, + "storage": { + "kind": "sqlite", + "owns_schema": true, + "scope": "project" + }, + "vault_grants": [] + }, + "consumes": [], + "module_id": "aft-tools", + "module_version": "1.2.3", + "protocol_ver": 2, + "provenance": { + "build_commit": "0123456789abcdef0123456789abcdef01234567-dirty", + "build_lock_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "store_schema_version": "42", + "wire_crate_version": "0.13.0" + }, + "provides": [ + { + "concurrency": "module_managed", + "emits_push": true, + "identity_scope": [ + "project", + "session" + ], + "role": "tool_provider", + "sub_supervises": true, + "tools": [ + { + "execution_mode": "pure", + "name": "memory.read", + "schema": { + "required": [ + "id" + ], + "type": "object" + } + } + ] + } + ], + "trust_tier": "first_party" + }, + "protocol_ver": 2 +} diff --git a/crates/subc-protocol/tests/golden_json.rs b/crates/subc-protocol/tests/golden_json.rs index 095a8daa..e130b9a4 100644 --- a/crates/subc-protocol/tests/golden_json.rs +++ b/crates/subc-protocol/tests/golden_json.rs @@ -1,14 +1,14 @@ use std::{fmt::Debug, fs, path::PathBuf}; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; use subc_protocol::{ error_codes, manifest::{ Bindings, CapabilityDeclarations, Concurrency, ExecutionMode, IdentityBinding, - IdentityScope, ManagementOperation, ManagementOperationKind, ModuleManifest, - ObservabilityKind, ObservabilitySurface, ProviderRole, StorageBinding, StorageKind, - StorageScope, Tool, TrustTier, + IdentityScope, ManagementOperation, ManagementOperationKind, ManifestProvenance, + ModuleManifest, ObservabilityKind, ObservabilitySurface, ProviderRole, StorageBinding, + StorageKind, StorageScope, Tool, TrustTier, }, session::{ HealthStatus, ModuleControlPush, ModuleControlRequest, ModuleControlRequestFromModule, @@ -53,6 +53,10 @@ fn protocol_wire_shapes_match_golden_json_and_round_trip() { assert_golden("principal_direct", &Principal::Direct); assert_golden("principal_unverified", &Principal::Unverified); assert_golden("module_hello_body", &module_hello_body()); + assert_golden( + "module_hello_body_with_provenance", + &module_hello_body_with_provenance(), + ); assert_golden("module_hello_ack_body", &module_hello_ack_body()); assert_golden( "module_control_request_route_bind", @@ -132,6 +136,87 @@ fn protocol_wire_shapes_match_golden_json_and_round_trip() { ); } +#[derive(Deserialize)] +struct LegacyModuleManifest { + module_id: String, +} + +#[test] +fn manifest_without_provenance_preserves_the_existing_hello_wire_shape() { + let encoded = serde_json::to_value(module_hello_body()).expect("HELLO serializes"); + + assert!(encoded["manifest"].get("provenance").is_none()); + assert_eq!( + encoded, + serde_json::from_str::( + &fs::read_to_string(golden_path("module_hello_body")).expect("existing HELLO golden"), + ) + .expect("existing HELLO golden is JSON"), + "an absent provenance declaration must preserve the existing HELLO bytes" + ); +} + +#[test] +fn manifest_provenance_round_trips_all_facts_through_the_real_manifest_deserializer() { + let hello = module_hello_body_with_provenance(); + let encoded = serde_json::to_value(&hello).expect("HELLO serializes"); + + assert_eq!( + encoded["manifest"]["provenance"], + serde_json::json!({ + "build_commit": "0123456789abcdef0123456789abcdef01234567-dirty", + "build_lock_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "wire_crate_version": "0.13.0", + "store_schema_version": "42" + }) + ); + let decoded: ModuleHelloBody = serde_json::from_value(encoded).expect("HELLO deserializes"); + assert_eq!(decoded, hello); +} + +#[test] +fn manifest_provenance_omits_each_unavailable_fact_independently() { + for field in [ + "build_commit", + "build_lock_digest", + "wire_crate_version", + "store_schema_version", + ] { + let mut provenance = ManifestProvenance { + build_commit: Some("commit".to_string()), + build_lock_digest: Some("lock".to_string()), + wire_crate_version: Some("wire".to_string()), + store_schema_version: Some("schema".to_string()), + }; + match field { + "build_commit" => provenance.build_commit = None, + "build_lock_digest" => provenance.build_lock_digest = None, + "wire_crate_version" => provenance.wire_crate_version = None, + "store_schema_version" => provenance.store_schema_version = None, + _ => unreachable!("fixed provenance field list"), + } + + let encoded = serde_json::to_value(&provenance).expect("provenance serializes"); + assert!( + encoded.get(field).is_none(), + "{field} must be omitted when unavailable" + ); + let decoded: ManifestProvenance = + serde_json::from_value(encoded).expect("partial provenance deserializes"); + assert_eq!(decoded, provenance); + } +} + +#[test] +fn legacy_manifest_decoder_ignores_the_additive_provenance_block() { + let encoded = serde_json::to_value(module_hello_body_with_provenance().manifest) + .expect("current manifest serializes"); + + let legacy: LegacyModuleManifest = + serde_json::from_value(encoded).expect("old decoder ignores additive fields"); + assert_eq!(legacy.module_id, "aft-tools"); +} + #[test] fn deployed_management_surface_manifest_without_concurrency_defaults_to_module_managed() { let fixture = fs::read_to_string(golden_path( @@ -288,6 +373,19 @@ fn module_hello_body() -> ModuleHelloBody { } } +fn module_hello_body_with_provenance() -> ModuleHelloBody { + let mut hello = module_hello_body(); + hello.manifest.provenance = Some(ManifestProvenance { + build_commit: Some("0123456789abcdef0123456789abcdef01234567-dirty".to_string()), + build_lock_digest: Some( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(), + ), + wire_crate_version: Some("0.13.0".to_string()), + store_schema_version: Some("42".to_string()), + }); + hello +} + fn module_hello_ack_body() -> ModuleHelloAckBody { ModuleHelloAckBody { negotiated_ver: PROTOCOL_VERSION, @@ -341,6 +439,7 @@ fn module_manifest(module_id: &str) -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } @@ -378,6 +477,7 @@ fn management_surface_manifest(description: Option<&str>) -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } From 1e359d63ab454102bc9e112b7c11261c9dadf118 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:50:38 +0200 Subject: [PATCH 02/19] control: define source-tagged supervisor provenance wire --- Cargo.lock | 2 +- crates/subc-client-rs/Cargo.toml | 2 +- crates/subc-control/Cargo.toml | 2 +- crates/subc-control/src/lib.rs | 109 +++++++++++- ...equest_supervisor_provenance_filtered.json | 4 + .../client_control_response_catalog_list.json | 3 +- ...nse_catalog_list_without_capabilities.json | 3 +- ...og_list_without_operation_description.json | 3 +- ...ient_control_response_server_describe.json | 3 +- ...esponse_server_describe_with_counters.json | 3 +- ...sponse_supervisor_provenance_mismatch.json | 50 ++++++ ...sponse_supervisor_provenance_reported.json | 46 +++++ ...se_supervisor_provenance_unverifiable.json | 39 +++++ crates/subc-control/tests/golden_json.rs | 165 +++++++++++++++++- crates/subc-core/src/control.rs | 33 ++++ 15 files changed, 453 insertions(+), 14 deletions(-) create mode 100644 crates/subc-control/tests/golden/client_control_request_supervisor_provenance_filtered.json create mode 100644 crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json create mode 100644 crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json create mode 100644 crates/subc-control/tests/golden/client_control_response_supervisor_provenance_unverifiable.json diff --git a/Cargo.lock b/Cargo.lock index 84d0e674..c177fa55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "subc-control" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde", "serde_json", diff --git a/crates/subc-client-rs/Cargo.toml b/crates/subc-client-rs/Cargo.toml index cc4be1fd..a8dcc10a 100644 --- a/crates/subc-client-rs/Cargo.toml +++ b/crates/subc-client-rs/Cargo.toml @@ -11,7 +11,7 @@ repository = "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/cortexkit/subconscious" async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" -subc-control = { path = "../subc-control", version = "0.5" } +subc-control = { path = "../subc-control", version = "0.6" } subc-protocol = { path = "../subc-protocol", version = "0.13" } subc-transport = { path = "../subc-transport", version = "0.5" } tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] } diff --git a/crates/subc-control/Cargo.toml b/crates/subc-control/Cargo.toml index e0974838..abf639e5 100644 --- a/crates/subc-control/Cargo.toml +++ b/crates/subc-control/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "subc-control" -version = "0.5.0" +version = "0.6.0" edition = "2021" publish = true description = "Client-facing subc control-plane wire shapes." diff --git a/crates/subc-control/src/lib.rs b/crates/subc-control/src/lib.rs index 2bcbb013..432acc40 100644 --- a/crates/subc-control/src/lib.rs +++ b/crates/subc-control/src/lib.rs @@ -7,9 +7,11 @@ #![forbid(unsafe_code)] +use std::path::PathBuf; + use serde::{Deserialize, Serialize}; use subc_protocol::{ - manifest::{CapabilityDeclarations, ProviderRole}, + manifest::{CapabilityDeclarations, ManifestProvenance, ProviderRole}, session::HealthStatus, BindIdentity, RouteTarget, }; @@ -55,6 +57,7 @@ pub mod ops { pub const SUPERVISOR_STDERR_TAIL: &str = "supervisor.stderr_tail"; pub const SUPERVISOR_TERMINALS: &str = "supervisor.terminals"; pub const SUPERVISOR_ROUTES: &str = "supervisor.routes"; + pub const SUPERVISOR_PROVENANCE: &str = "supervisor.provenance"; } /// Client-originated channel-0 control RPC body. @@ -177,6 +180,13 @@ pub enum ClientControlRequest { #[serde(default, skip_serializing_if = "Option::is_none")] module_id: Option, }, + /// Report source-tagged provenance for supervised modules, optionally narrowed + /// to one module. + #[serde(rename = "supervisor.provenance")] + SupervisorProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + module_id: Option, + }, /// Retained stderr for one module. /// /// A separate op rather than a field on `supervisor.list`: the tail is @@ -287,6 +297,11 @@ pub enum ClientControlResponse { }, #[serde(rename = "supervisor.routes")] SupervisorRoutes { modules: Vec }, + #[serde(rename = "supervisor.provenance")] + SupervisorProvenance { + daemon: SupervisorDaemonProvenance, + modules: Vec, + }, #[serde(rename = "supervisor.stderr_tail")] SupervisorStderrTail { module_id: String, @@ -387,6 +402,98 @@ pub struct SupervisorRoute { pub drain_reason: Option, } +/// Source-tagged provenance for one supervised module. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SupervisorModuleProvenance { + pub module_id: String, + pub module_declared: ModuleDeclaredProvenance, + pub daemon_observed: SupervisorObservedProcess, +} + +/// A module's declared build metadata, if its HELLO manifest carried it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ModuleDeclaredProvenance { + Reported { build: ManifestProvenance }, + Unverifiable, +} + +/// Process facts observed by the daemon for a supervised module. +/// +/// Build claims remain under `module_declared`; mixing them here would imply the +/// daemon independently observed module-provided metadata. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SupervisorObservedProcess { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawned_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawned_from: Option, + pub running_image: RunningImageAgreement, +} + +/// Daemon provenance paired with its runtime process observation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SupervisorDaemonProvenance { + pub daemon_build: DaemonBuildProvenance, + pub daemon_observed: DaemonObservedProcess, +} + +/// Build metadata embedded in the daemon binary. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DaemonBuildProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_git_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_lock_digest: Option, +} + +/// Runtime process facts observed for the daemon itself. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DaemonObservedProcess { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at_ms: Option, + pub running_image: RunningImageAgreement, +} + +/// Whether the executable currently running agrees with the spawned image. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum RunningImageAgreement { + Match { + evidence: RunningImageEvidence, + }, + Mismatch { + running: RunningImageEvidence, + disk: RunningImageEvidence, + }, + Unavailable { + reason: RunningImageUnavailableReason, + }, +} + +/// Platform-specific evidence used to compare a running image with its spawn path. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "method", rename_all = "snake_case")] +pub enum RunningImageEvidence { + LinuxProcSha256 { digest: String }, + MacosSpawnInode { device: u64, inode: u64 }, +} + +/// Closed reasons why an executable identity could not be observed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RunningImageUnavailableReason { + NotRunning, + UnsupportedPlatform, + RunningExecutableUnreadable, + SpawnedPathUnreadable, + HashFailed, +} + /// The identity tier the daemon can honestly report for a route consumer. /// /// A caller that proved a live daemon-issued launch nonce is named `reserved`. diff --git a/crates/subc-control/tests/golden/client_control_request_supervisor_provenance_filtered.json b/crates/subc-control/tests/golden/client_control_request_supervisor_provenance_filtered.json new file mode 100644 index 00000000..c4c9562e --- /dev/null +++ b/crates/subc-control/tests/golden/client_control_request_supervisor_provenance_filtered.json @@ -0,0 +1,4 @@ +{ + "module_id": "aft", + "op": "supervisor.provenance" +} diff --git a/crates/subc-control/tests/golden/client_control_response_catalog_list.json b/crates/subc-control/tests/golden/client_control_response_catalog_list.json index 5ab1f7f6..17a8f7dd 100644 --- a/crates/subc-control/tests/golden/client_control_response_catalog_list.json +++ b/crates/subc-control/tests/golden/client_control_response_catalog_list.json @@ -112,6 +112,7 @@ "supervisor.health", "supervisor.stderr_tail", "supervisor.terminals", - "supervisor.routes" + "supervisor.routes", + "supervisor.provenance" ] } diff --git a/crates/subc-control/tests/golden/client_control_response_catalog_list_without_capabilities.json b/crates/subc-control/tests/golden/client_control_response_catalog_list_without_capabilities.json index 21badebc..a3e077ab 100644 --- a/crates/subc-control/tests/golden/client_control_response_catalog_list_without_capabilities.json +++ b/crates/subc-control/tests/golden/client_control_response_catalog_list_without_capabilities.json @@ -29,6 +29,7 @@ "supervisor.health", "supervisor.stderr_tail", "supervisor.terminals", - "supervisor.routes" + "supervisor.routes", + "supervisor.provenance" ] } diff --git a/crates/subc-control/tests/golden/client_control_response_catalog_list_without_operation_description.json b/crates/subc-control/tests/golden/client_control_response_catalog_list_without_operation_description.json index 2b4d8396..bc396b51 100644 --- a/crates/subc-control/tests/golden/client_control_response_catalog_list_without_operation_description.json +++ b/crates/subc-control/tests/golden/client_control_response_catalog_list_without_operation_description.json @@ -44,6 +44,7 @@ "supervisor.health", "supervisor.stderr_tail", "supervisor.terminals", - "supervisor.routes" + "supervisor.routes", + "supervisor.provenance" ] } diff --git a/crates/subc-control/tests/golden/client_control_response_server_describe.json b/crates/subc-control/tests/golden/client_control_response_server_describe.json index 76af581e..11ee253c 100644 --- a/crates/subc-control/tests/golden/client_control_response_server_describe.json +++ b/crates/subc-control/tests/golden/client_control_response_server_describe.json @@ -22,6 +22,7 @@ "supervisor.health", "supervisor.stderr_tail", "supervisor.terminals", - "supervisor.routes" + "supervisor.routes", + "supervisor.provenance" ] } diff --git a/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json b/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json index 42680fd6..def86e31 100644 --- a/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json +++ b/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json @@ -41,6 +41,7 @@ "supervisor.health", "supervisor.stderr_tail", "supervisor.terminals", - "supervisor.routes" + "supervisor.routes", + "supervisor.provenance" ] } diff --git a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json new file mode 100644 index 00000000..b3a37711 --- /dev/null +++ b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json @@ -0,0 +1,50 @@ +{ + "daemon": { + "daemon_build": { + "build_git_sha": "fedcba9876543210fedcba9876543210fedcba98", + "build_lock_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "daemon_observed": { + "pid": 4400, + "running_image": { + "evidence": { + "digest": "3333333333333333333333333333333333333333333333333333333333333333", + "method": "linux_proc_sha256" + }, + "status": "match" + }, + "started_at_ms": 1725000000005 + } + }, + "modules": [ + { + "daemon_observed": { + "pid": 4401, + "running_image": { + "disk": { + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "method": "linux_proc_sha256" + }, + "running": { + "digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "method": "linux_proc_sha256" + }, + "status": "mismatch" + }, + "spawned_at_ms": 1725000000006, + "spawned_from": "/opt/subc/bin/mcp" + }, + "module_declared": { + "build": { + "build_commit": "fedcba9876543210fedcba9876543210fedcba98-dirty", + "build_lock_digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "store_schema_version": "3", + "wire_crate_version": "0.13.0" + }, + "status": "reported" + }, + "module_id": "mcp" + } + ], + "op": "supervisor.provenance" +} diff --git a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json new file mode 100644 index 00000000..07fbfe27 --- /dev/null +++ b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json @@ -0,0 +1,46 @@ +{ + "daemon": { + "daemon_build": { + "build_git_sha": "0123456789abcdef0123456789abcdef01234567-dirty", + "build_lock_digest": "9d2c0d69cd82f2151bbb2b32ab9ac9d861063ffde2f8582afe767ec7e1f2145c" + }, + "daemon_observed": { + "pid": 4200, + "running_image": { + "evidence": { + "digest": "1111111111111111111111111111111111111111111111111111111111111111", + "method": "linux_proc_sha256" + }, + "status": "match" + }, + "started_at_ms": 1725000000001 + } + }, + "modules": [ + { + "daemon_observed": { + "pid": 4201, + "running_image": { + "evidence": { + "digest": "2222222222222222222222222222222222222222222222222222222222222222", + "method": "linux_proc_sha256" + }, + "status": "match" + }, + "spawned_at_ms": 1725000000002, + "spawned_from": "/opt/subc/bin/aft" + }, + "module_declared": { + "build": { + "build_commit": "0123456789abcdef0123456789abcdef01234567", + "build_lock_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "store_schema_version": "2", + "wire_crate_version": "0.13.0" + }, + "status": "reported" + }, + "module_id": "aft" + } + ], + "op": "supervisor.provenance" +} diff --git a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_unverifiable.json b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_unverifiable.json new file mode 100644 index 00000000..0ec3976d --- /dev/null +++ b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_unverifiable.json @@ -0,0 +1,39 @@ +{ + "daemon": { + "daemon_build": {}, + "daemon_observed": { + "pid": 4300, + "running_image": { + "evidence": { + "device": 2049, + "inode": 99001, + "method": "macos_spawn_inode" + }, + "status": "match" + }, + "started_at_ms": 1725000000003 + } + }, + "modules": [ + { + "daemon_observed": { + "pid": 4301, + "running_image": { + "evidence": { + "device": 2049, + "inode": 99002, + "method": "macos_spawn_inode" + }, + "status": "match" + }, + "spawned_at_ms": 1725000000004, + "spawned_from": "/opt/subc/bin/vault" + }, + "module_declared": { + "status": "unverifiable" + }, + "module_id": "vault" + } + ], + "op": "supervisor.provenance" +} diff --git a/crates/subc-control/tests/golden_json.rs b/crates/subc-control/tests/golden_json.rs index 3a05432a..5dacbc5d 100644 --- a/crates/subc-control/tests/golden_json.rs +++ b/crates/subc-control/tests/golden_json.rs @@ -4,16 +4,18 @@ use serde::{de::DeserializeOwned, Serialize}; use serde_json::Value; use subc_control::{ CatalogEntry, ClientControlPush, ClientControlRequest, ClientControlResponse, ConsumerIdentity, - PollKind, RouteCloseReason, StderrCaptureState, StderrTail, StderrTailEntry, SupervisorEntry, - SupervisorHealthEntry, SupervisorHealthStatus, SupervisorRescanResult, SupervisorRoute, - SupervisorRouteConsumer, SupervisorRouteModule, + DaemonBuildProvenance, DaemonObservedProcess, ModuleDeclaredProvenance, PollKind, + RouteCloseReason, RunningImageAgreement, RunningImageEvidence, StderrCaptureState, StderrTail, + StderrTailEntry, SupervisorDaemonProvenance, SupervisorEntry, SupervisorHealthEntry, + SupervisorHealthStatus, SupervisorModuleProvenance, SupervisorObservedProcess, + SupervisorRescanResult, SupervisorRoute, SupervisorRouteConsumer, SupervisorRouteModule, }; use subc_protocol::{ manifest::{ CapabilityDeclarations, CapabilityNeed, CapabilityRequirement, Concurrency, ExecutionMode, IdentityScope, InternalTransport, ManagementOperation, ManagementOperationKind, - ObservabilityKind, ObservabilitySurface, PipelineAppliesTo, PipelineStageKind, - ProviderRole, Tool, + ManifestProvenance, ObservabilityKind, ObservabilitySurface, PipelineAppliesTo, + PipelineStageKind, ProviderRole, Tool, }, session::HealthStatus, BindIdentity, RouteTarget, PROTOCOL_VERSION, @@ -208,6 +210,12 @@ fn client_control_requests() -> Vec<(&'static str, ClientControlRequest)> { "client_control_request_supervisor_health", ClientControlRequest::SupervisorHealth {}, ), + ( + "client_control_request_supervisor_provenance_filtered", + ClientControlRequest::SupervisorProvenance { + module_id: Some("aft".to_string()), + }, + ), ] } @@ -357,6 +365,152 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { }], }, ), + ( + "client_control_response_supervisor_provenance_reported", + ClientControlResponse::SupervisorProvenance { + daemon: SupervisorDaemonProvenance { + daemon_build: DaemonBuildProvenance { + build_git_sha: Some( + "0123456789abcdef0123456789abcdef01234567-dirty".to_string(), + ), + build_lock_digest: Some( + "9d2c0d69cd82f2151bbb2b32ab9ac9d861063ffde2f8582afe767ec7e1f2145c" + .to_string(), + ), + }, + daemon_observed: DaemonObservedProcess { + pid: Some(4200), + started_at_ms: Some(1_725_000_000_001), + running_image: RunningImageAgreement::Match { + evidence: RunningImageEvidence::LinuxProcSha256 { + digest: "1111111111111111111111111111111111111111111111111111111111111111" + .to_string(), + }, + }, + }, + }, + modules: vec![SupervisorModuleProvenance { + module_id: "aft".to_string(), + module_declared: ModuleDeclaredProvenance::Reported { + build: ManifestProvenance { + build_commit: Some( + "0123456789abcdef0123456789abcdef01234567".to_string(), + ), + build_lock_digest: Some( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + ), + wire_crate_version: Some("0.13.0".to_string()), + store_schema_version: Some("2".to_string()), + }, + }, + daemon_observed: SupervisorObservedProcess { + pid: Some(4201), + spawned_at_ms: Some(1_725_000_000_002), + spawned_from: Some(PathBuf::from("/opt/subc/bin/aft")), + running_image: RunningImageAgreement::Match { + evidence: RunningImageEvidence::LinuxProcSha256 { + digest: "2222222222222222222222222222222222222222222222222222222222222222" + .to_string(), + }, + }, + }, + }], + }, + ), + ( + "client_control_response_supervisor_provenance_unverifiable", + ClientControlResponse::SupervisorProvenance { + daemon: SupervisorDaemonProvenance { + daemon_build: DaemonBuildProvenance { + build_git_sha: None, + build_lock_digest: None, + }, + daemon_observed: DaemonObservedProcess { + pid: Some(4300), + started_at_ms: Some(1_725_000_000_003), + running_image: RunningImageAgreement::Match { + evidence: RunningImageEvidence::MacosSpawnInode { + device: 2_049, + inode: 99_001, + }, + }, + }, + }, + modules: vec![SupervisorModuleProvenance { + module_id: "vault".to_string(), + module_declared: ModuleDeclaredProvenance::Unverifiable, + daemon_observed: SupervisorObservedProcess { + pid: Some(4301), + spawned_at_ms: Some(1_725_000_000_004), + spawned_from: Some(PathBuf::from("/opt/subc/bin/vault")), + running_image: RunningImageAgreement::Match { + evidence: RunningImageEvidence::MacosSpawnInode { + device: 2_049, + inode: 99_002, + }, + }, + }, + }], + }, + ), + ( + "client_control_response_supervisor_provenance_mismatch", + ClientControlResponse::SupervisorProvenance { + daemon: SupervisorDaemonProvenance { + daemon_build: DaemonBuildProvenance { + build_git_sha: Some( + "fedcba9876543210fedcba9876543210fedcba98".to_string(), + ), + build_lock_digest: Some( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(), + ), + }, + daemon_observed: DaemonObservedProcess { + pid: Some(4400), + started_at_ms: Some(1_725_000_000_005), + running_image: RunningImageAgreement::Match { + evidence: RunningImageEvidence::LinuxProcSha256 { + digest: "3333333333333333333333333333333333333333333333333333333333333333" + .to_string(), + }, + }, + }, + }, + modules: vec![SupervisorModuleProvenance { + module_id: "mcp".to_string(), + module_declared: ModuleDeclaredProvenance::Reported { + build: ManifestProvenance { + build_commit: Some( + "fedcba9876543210fedcba9876543210fedcba98-dirty".to_string(), + ), + build_lock_digest: Some( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + .to_string(), + ), + wire_crate_version: Some("0.13.0".to_string()), + store_schema_version: Some("3".to_string()), + }, + }, + daemon_observed: SupervisorObservedProcess { + pid: Some(4401), + spawned_at_ms: Some(1_725_000_000_006), + spawned_from: Some(PathBuf::from("/opt/subc/bin/mcp")), + running_image: RunningImageAgreement::Mismatch { + running: RunningImageEvidence::LinuxProcSha256 { + digest: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + .to_string(), + }, + disk: RunningImageEvidence::LinuxProcSha256 { + digest: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + .to_string(), + }, + }, + }, + }], + }, + ), ( "client_control_response_supervisor_stderr_tail", ClientControlResponse::SupervisorStderrTail { @@ -526,6 +680,7 @@ fn thin_core_ops() -> Vec { "supervisor.stderr_tail".to_string(), "supervisor.terminals".to_string(), "supervisor.routes".to_string(), + "supervisor.provenance".to_string(), ] } diff --git a/crates/subc-core/src/control.rs b/crates/subc-core/src/control.rs index 246bce3f..731870d3 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -1226,6 +1226,12 @@ impl ControlHandler { ClientControlRequest::SupervisorRoutes { module_id } => { self.handle_supervisor_routes(frame, module_id) } + // exhaustiveness arm: replaced by the real handler in the provenance daemon slice (task 4) + ClientControlRequest::SupervisorProvenance { .. } => Ok(vec![control_error_frame( + &frame, + "unknown_control_op", + "unknown client control op: supervisor.provenance", + )?]), ClientControlRequest::SupervisorStderrTail { module_id, max_lines, @@ -6623,6 +6629,33 @@ mod tests { assert_eq!(parse_error(&response[0])["code"], "unknown_control_op"); } + #[tokio::test] + async fn supervisor_provenance_is_refused_until_handler_lands() { + let handler = ControlHandler::default(); + let (ctx, _rx) = route_ctx(ConnectionId::new(79)); + let request = Frame::build( + FrameType::Request, + control_flags(), + 0, + 0, + 57, + br#"{"op":"supervisor.provenance"}"#.to_vec(), + ) + .unwrap(); + + let response = handler.handle_control_frame(&ctx, request).await.unwrap(); + + assert_eq!(response.len(), 1); + assert_eq!(response[0].header.ty, FrameType::Error); + assert_eq!(response[0].header.corr, 57); + let error = parse_error(&response[0]); + assert_eq!(error["code"], "unknown_control_op"); + assert_eq!( + error["message"], + "unknown client control op: supervisor.provenance" + ); + } + #[tokio::test] async fn malformed_control_bodies_return_invalid_control_body() { let handler = ControlHandler::default(); From 73d2adc4b20827c72ae5f725e814ec4c72586bc0 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Wed, 26 Aug 2026 15:30:41 +0300 Subject: [PATCH 03/19] # supervisor: retain spawn facts and probe executable identity --- Cargo.lock | 1 + crates/subc-core/Cargo.toml | 1 + crates/subc-core/src/lib.rs | 2 + crates/subc-core/src/provenance.rs | 387 ++++++++++++++++++++++++++ crates/subc-core/src/supervise.rs | 104 +++++-- crates/subc-core/tests/supervision.rs | 63 +++++ 6 files changed, 528 insertions(+), 30 deletions(-) create mode 100644 crates/subc-core/src/provenance.rs diff --git a/Cargo.lock b/Cargo.lock index c177fa55..1c8dec64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -905,6 +905,7 @@ dependencies = [ "rlimit", "serde", "serde_json", + "sha2", "subc-control", "subc-jsonc", "subc-protocol", diff --git a/crates/subc-core/Cargo.toml b/crates/subc-core/Cargo.toml index c1115c96..72c4d90f 100644 --- a/crates/subc-core/Cargo.toml +++ b/crates/subc-core/Cargo.toml @@ -31,6 +31,7 @@ getrandom = "0.2" rlimit = "0.11" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" subc-control = { path = "../subc-control" } subc-jsonc = { path = "../subc-jsonc" } terminal_size = "0.4" diff --git a/crates/subc-core/src/lib.rs b/crates/subc-core/src/lib.rs index 7c0c1f7a..d99b10fa 100644 --- a/crates/subc-core/src/lib.rs +++ b/crates/subc-core/src/lib.rs @@ -16,6 +16,8 @@ pub mod fleet_lint; pub mod forwarding; pub mod identity; pub mod observability; +#[allow(dead_code)] +mod provenance; pub mod registry; pub mod router; pub mod server; diff --git a/crates/subc-core/src/provenance.rs b/crates/subc-core/src/provenance.rs new file mode 100644 index 00000000..ec75d29a --- /dev/null +++ b/crates/subc-core/src/provenance.rs @@ -0,0 +1,387 @@ +use std::path::Path; + +#[cfg(target_os = "linux")] +use std::{ + collections::HashMap, + fs::File, + io::{self, Read}, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +#[cfg(target_os = "linux")] +use sha2::{Digest, Sha256}; +use subc_control::{RunningImageAgreement, RunningImageEvidence, RunningImageUnavailableReason}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SpawnedFileIdentity { + pub(crate) device: u64, + pub(crate) inode: u64, +} + +pub(crate) fn spawned_file_identity(path: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + std::fs::metadata(path) + .ok() + .map(|metadata| SpawnedFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) + } + + #[cfg(not(unix))] + { + let _ = path; + None + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ExecutableIdentityProbe { + #[cfg(target_os = "linux")] + cache: Arc>, +} + +impl ExecutableIdentityProbe { + pub(crate) async fn observe( + &self, + pid: Option, + spawned_from: Option<&Path>, + _spawned_identity: Option, + ) -> RunningImageAgreement { + let Some(pid) = pid else { + return unavailable(RunningImageUnavailableReason::NotRunning); + }; + let Some(spawned_from) = spawned_from else { + return unavailable(RunningImageUnavailableReason::SpawnedPathUnreadable); + }; + + #[cfg(target_os = "linux")] + { + let cache = Arc::clone(&self.cache); + let spawned_from = spawned_from.to_path_buf(); + tokio::task::spawn_blocking(move || { + let mut cache = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + compare_opened_paths( + &mut cache, + &PathBuf::from(format!("/proc/{pid}/exe")), + &spawned_from, + ) + }) + .await + .unwrap_or_else(|_| unavailable(RunningImageUnavailableReason::HashFailed)) + } + + #[cfg(target_os = "macos")] + { + let _ = pid; + match (_spawned_identity, spawned_file_identity(spawned_from)) { + (Some(spawned_identity), Some(current_identity)) => { + compare_spawn_inode(spawned_identity, current_identity) + } + _ => unavailable(RunningImageUnavailableReason::SpawnedPathUnreadable), + } + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (pid, spawned_from, spawned_identity); + unavailable(RunningImageUnavailableReason::UnsupportedPlatform) + } + } +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Default)] +struct ImageDigestCache { + digests: HashMap, + #[cfg(all(test, target_os = "linux"))] + digest_computations: usize, +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct FileCacheKey { + device: u64, + inode: u64, + size: u64, + mtime_sec: i64, + mtime_nsec: i64, +} + +#[cfg(target_os = "linux")] +fn compare_opened_paths( + cache: &mut ImageDigestCache, + running_path: &Path, + spawned_path: &Path, +) -> RunningImageAgreement { + let running = match File::open(running_path) { + Ok(file) => file, + Err(_) => return unavailable(RunningImageUnavailableReason::RunningExecutableUnreadable), + }; + let disk = match File::open(spawned_path) { + Ok(file) => file, + Err(_) => return unavailable(RunningImageUnavailableReason::SpawnedPathUnreadable), + }; + let running = match digest_open_file(cache, running) { + Ok(digest) => digest, + Err(_) => return unavailable(RunningImageUnavailableReason::HashFailed), + }; + let disk = match digest_open_file(cache, disk) { + Ok(digest) => digest, + Err(_) => return unavailable(RunningImageUnavailableReason::HashFailed), + }; + let running = RunningImageEvidence::LinuxProcSha256 { digest: running }; + let disk = RunningImageEvidence::LinuxProcSha256 { digest: disk }; + if running == disk { + RunningImageAgreement::Match { evidence: running } + } else { + RunningImageAgreement::Mismatch { running, disk } + } +} + +#[cfg(target_os = "linux")] +fn digest_open_file(cache: &mut ImageDigestCache, mut file: File) -> io::Result { + let key = cache_key(&file)?; + if let Some(digest) = cache.digests.get(&key) { + return Ok(digest.clone()); + } + + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let digest = format!("{:x}", hasher.finalize()); + if cache.digests.len() == 64 { + cache.digests.clear(); + } + cache.digests.insert(key, digest.clone()); + #[cfg(test)] + { + cache.digest_computations += 1; + } + Ok(digest) +} + +#[cfg(target_os = "linux")] +fn cache_key(file: &File) -> io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(FileCacheKey { + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + mtime_sec: metadata.mtime(), + mtime_nsec: metadata.mtime_nsec(), + }) +} + +#[cfg(any(target_os = "macos", test))] +fn compare_spawn_inode( + spawned: SpawnedFileIdentity, + current: SpawnedFileIdentity, +) -> RunningImageAgreement { + let running = RunningImageEvidence::MacosSpawnInode { + device: spawned.device, + inode: spawned.inode, + }; + let disk = RunningImageEvidence::MacosSpawnInode { + device: current.device, + inode: current.inode, + }; + if running == disk { + RunningImageAgreement::Match { evidence: running } + } else { + RunningImageAgreement::Mismatch { running, disk } + } +} + +fn unavailable(reason: RunningImageUnavailableReason) -> RunningImageAgreement { + RunningImageAgreement::Unavailable { reason } +} + +#[cfg(all(test, target_os = "linux"))] +impl ImageDigestCache { + fn len(&self) -> usize { + self.digests.len() + } + + fn digest_computations(&self) -> usize { + self.digest_computations + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs::{self, File}, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + use subc_control::{RunningImageAgreement, RunningImageUnavailableReason}; + + fn temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "subc-provenance-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + path + } + + #[cfg(target_os = "linux")] + #[test] + fn equal_opened_images_match() { + let dir = temp_dir("equal"); + let left = dir.join("left"); + let right = dir.join("right"); + fs::write(&left, b"same executable image").unwrap(); + fs::write(&right, b"same executable image").unwrap(); + + let mut cache = ImageDigestCache::default(); + let agreement = compare_opened_paths(&mut cache, &left, &right); + + assert!(matches!(agreement, RunningImageAgreement::Match { .. })); + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn changed_opened_image_mismatches_with_distinct_digests() { + let dir = temp_dir("mismatch"); + let left = dir.join("left"); + let right = dir.join("right"); + fs::write(&left, b"original executable image").unwrap(); + fs::write(&right, b"original executable image").unwrap(); + fs::write(&right, b"mutated executable image with a different size").unwrap(); + + let mut cache = ImageDigestCache::default(); + let agreement = compare_opened_paths(&mut cache, &left, &right); + + match agreement { + RunningImageAgreement::Mismatch { running, disk } => assert_ne!(running, disk), + other => panic!("expected distinct digests after mutation, got {other:?}"), + } + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn missing_image_is_typed_unavailable() { + let dir = temp_dir("missing"); + let left = dir.join("left"); + fs::write(&left, b"existing executable image").unwrap(); + + let mut cache = ImageDigestCache::default(); + let agreement = compare_opened_paths(&mut cache, &left, &dir.join("missing")); + + assert_eq!( + agreement, + RunningImageAgreement::Unavailable { + reason: RunningImageUnavailableReason::SpawnedPathUnreadable, + } + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn missing_running_image_is_typed_unavailable() { + let dir = temp_dir("missing-running"); + let disk = dir.join("disk"); + fs::write(&disk, b"existing spawned image").unwrap(); + + let mut cache = ImageDigestCache::default(); + let agreement = compare_opened_paths(&mut cache, &dir.join("missing"), &disk); + + assert_eq!( + agreement, + RunningImageAgreement::Unavailable { + reason: RunningImageUnavailableReason::RunningExecutableUnreadable, + } + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn cache_reuses_an_opened_identity_and_invalidates_changed_metadata() { + let dir = temp_dir("cache"); + let image = dir.join("image"); + fs::write(&image, b"first executable image").unwrap(); + + let mut cache = ImageDigestCache::default(); + let first = digest_open_file(&mut cache, File::open(&image).unwrap()).unwrap(); + let computations_after_first = cache.digest_computations(); + let repeated = digest_open_file(&mut cache, File::open(&image).unwrap()).unwrap(); + assert_eq!(first, repeated); + assert_eq!(cache.digest_computations(), computations_after_first); + + fs::write(&image, b"second executable image with a different size").unwrap(); + let changed = digest_open_file(&mut cache, File::open(&image).unwrap()).unwrap(); + assert_ne!(first, changed); + assert_eq!(cache.digest_computations(), computations_after_first + 1); + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn cache_clears_before_storing_the_sixty_fifth_identity() { + let dir = temp_dir("cache-bound"); + let mut cache = ImageDigestCache::default(); + for index in 0..65 { + let image = dir.join(format!("image-{index}")); + fs::write(&image, format!("image-{index}")).unwrap(); + digest_open_file(&mut cache, File::open(image).unwrap()).unwrap(); + } + + assert_eq!( + cache.len(), + 1, + "the 65th identity clears the 64-entry cache" + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn spawn_inode_comparator_reports_path_replacement_without_claiming_a_hash() { + let spawned = SpawnedFileIdentity { + device: 7, + inode: 11, + }; + let same_path = SpawnedFileIdentity { + device: 7, + inode: 11, + }; + let replacement = SpawnedFileIdentity { + device: 7, + inode: 12, + }; + + assert!(matches!( + compare_spawn_inode(spawned, same_path), + RunningImageAgreement::Match { .. } + )); + assert!(matches!( + compare_spawn_inode(spawned, replacement), + RunningImageAgreement::Mismatch { .. } + )); + } +} diff --git a/crates/subc-core/src/supervise.rs b/crates/subc-core/src/supervise.rs index 312e15ec..f2ea4355 100644 --- a/crates/subc-core/src/supervise.rs +++ b/crates/subc-core/src/supervise.rs @@ -29,6 +29,7 @@ use crate::{ CloseReason, ForwardingError, ForwardingTable, GoodbyeTarget, ModuleControlRpcOutcome, ModuleDrainTarget, PendingModuleControlRpc, }, + provenance::{spawned_file_identity, ExecutableIdentityProbe, SpawnedFileIdentity}, registry::RegistryError, stderr_tail::{pump_stderr, StderrRing, StderrTailConfig, StderrTailSnapshot}, terminal_ring::{TerminalHistorySnapshot, TerminalRecord, TerminalRing, TerminalRingConfig}, @@ -63,6 +64,9 @@ struct SupervisedChild { child: Child, stderr_pump: Option>, stderr_ring: Arc>, + spawned_at_ms: u64, + spawned_from: PathBuf, + spawned_file_identity: Option, } impl SupervisedChild { @@ -334,6 +338,8 @@ pub struct ModuleStatus { /// about-to-be-retired module look ordinary. pub max_restarts: u32, pub pid: Option, + pub spawned_at_ms: Option, + pub spawned_from: Option, pub last_exit: Option, pub health: ModuleHealthStatus, } @@ -345,6 +351,9 @@ struct SupervisorSnapshot { process_alive: bool, restart_count: u32, pid: Option, + spawned_at_ms: Option, + spawned_from: Option, + spawned_file_identity: Option, last_exit: Option, health: ModuleHealthStatus, } @@ -369,6 +378,9 @@ impl SupervisorSnapshot { process_alive: false, restart_count: 0, pid: None, + spawned_at_ms: None, + spawned_from: None, + spawned_file_identity: None, last_exit: None, health: ModuleHealthStatus::default(), } @@ -810,6 +822,8 @@ pub struct Supervisor { supervisor_handle: Option, health: HealthConfig, daemon_started_at_ms: u64, + #[allow(dead_code)] + provenance_probe: ExecutableIdentityProbe, } impl Supervisor { @@ -824,6 +838,7 @@ impl Supervisor { supervisor_handle: None, health: HealthConfig::default(), daemon_started_at_ms: unix_ms_now(), + provenance_probe: ExecutableIdentityProbe::default(), } } @@ -876,7 +891,7 @@ impl Supervisor { self.supervisor_handle.as_ref(), &runtime.stderr_ring, )?; - set_running(&snapshot, child.id())?; + set_running(&snapshot, &child)?; self.process_liveness .track(spec.module_id.clone(), Arc::clone(&snapshot)); @@ -909,7 +924,7 @@ impl Supervisor { &runtime.stderr_ring, ) { Ok(child) => { - set_running(&snapshot, child.id())?; + set_running(&snapshot, &child)?; self.process_liveness .track(spec.module_id.clone(), Arc::clone(&snapshot)); Ok(self.supervised_module(spec, runtime, snapshot, Some(child))) @@ -954,7 +969,7 @@ impl Supervisor { &runtime.stderr_ring, ) { Ok(child) => { - set_running(&snapshot, child.id())?; + set_running(&snapshot, &child)?; self.process_liveness .track(spec.module_id.clone(), Arc::clone(&snapshot)); Ok(self.supervised_module(spec, runtime, snapshot, Some(child))) @@ -1034,6 +1049,7 @@ impl Supervisor { commands: tx, monitor: Mutex::new(Some(monitor)), max_restarts: self.restart_policy.max_restarts, + provenance_probe: self.provenance_probe.clone(), }), }; if let Some(supervisor_handle) = &self.supervisor_handle { @@ -1069,6 +1085,7 @@ struct SupervisedModuleInner { /// report the restart budget without reaching back into the supervisor. The /// policy is fixed for the process's lifetime, so a copy cannot drift. max_restarts: u32, + provenance_probe: ExecutableIdentityProbe, } impl fmt::Debug for SupervisedModule { @@ -1179,6 +1196,8 @@ impl SupervisedModule { restart_count: snapshot.restart_count, max_restarts: self.inner.max_restarts, pid: snapshot.pid, + spawned_at_ms: snapshot.spawned_at_ms, + spawned_from: snapshot.spawned_from, last_exit: snapshot.last_exit, health: snapshot.health, }) @@ -1200,6 +1219,26 @@ impl SupervisedModule { }) } + #[allow(dead_code)] + pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement { + let snapshot = match lock_snapshot(&self.inner.snapshot) { + Ok(snapshot) => snapshot.clone(), + Err(_) => { + return subc_control::RunningImageAgreement::Unavailable { + reason: subc_control::RunningImageUnavailableReason::NotRunning, + }; + } + }; + self.inner + .provenance_probe + .observe( + snapshot.pid, + snapshot.spawned_from.as_deref(), + snapshot.spawned_file_identity, + ) + .await + } + pub(crate) fn will_recover_after_connection_loss(&self) -> Result { let snapshot = lock_snapshot(&self.inner.snapshot)?.clone(); Ok(match snapshot.state { @@ -1403,8 +1442,7 @@ impl Drop for SupervisedModuleInner { if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) { let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| { state.state = ModuleState::Stopped; - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); }); monitor.abort(); } @@ -2614,8 +2652,7 @@ async fn handle_supervisor_command( ); let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| { state.state = ModuleState::Failed; - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); }); } } @@ -2702,8 +2739,7 @@ async fn restart_child( update_snapshot(snapshot, Some(&spec.module_id), |state| { state.enabled = true; state.state = ModuleState::Restarting; - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); })?; wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?; } @@ -2770,8 +2806,7 @@ async fn reload_child( update_snapshot(snapshot, Some(&spec.module_id), |state| { state.enabled = true; state.state = ModuleState::Restarting; - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); })?; wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?; } @@ -2900,8 +2935,7 @@ async fn set_child_enabled( update_snapshot(snapshot, Some(&spec.module_id), |state| { state.enabled = true; state.state = ModuleState::Starting; - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); })?; wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?; reset_restart_count(snapshot, &spec.module_id)?; @@ -2911,8 +2945,7 @@ async fn set_child_enabled( Err(err) => { if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| { state.state = ModuleState::Failed; - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); }) { error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure"); } @@ -2966,8 +2999,7 @@ async fn on_child_exit( ); if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| { state.state = ModuleState::Stopped; - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); state.last_exit = Some(exit_report.clone()); }) { error!(module_id = %spec.module_id, error = %err, "failed to record clean module exit"); @@ -3000,8 +3032,7 @@ async fn on_child_exit( let mut should_restart = false; let mut disposition = TerminalDisposition::Disabled; if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| { - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); state.last_exit = Some(exit_report.clone()); if daemon_will_restart(state.enabled, state.restart_count, policy.max_restarts) { state.restart_count += 1; @@ -3147,6 +3178,9 @@ fn spawn_child( program: spec.program.clone(), source, })?; + let spawned_at_ms = unix_ms_now(); + let spawned_from = spec.program.clone(); + let spawned_file_identity = spawned_file_identity(&spawned_from); let stderr_pump = match child.stderr.take() { Some(stderr) => { @@ -3174,6 +3208,9 @@ fn spawn_child( child, stderr_pump, stderr_ring: Arc::clone(ring), + spawned_at_ms, + spawned_from, + spawned_file_identity, }) } @@ -3216,7 +3253,7 @@ fn spawn_and_mark_running( runtime.supervisor_handle.as_ref(), &runtime.stderr_ring, )?; - set_running(snapshot, child.id())?; + set_running(snapshot, &child)?; Ok(child) } @@ -3652,8 +3689,7 @@ async fn handle_reload_spawn_failure( ) -> Result<(), SuperviseError> { let mut should_retry = false; update_snapshot(snapshot, Some(&spec.module_id), |state| { - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); if daemon_will_restart( state.enabled, state.restart_count, @@ -3728,8 +3764,7 @@ async fn drain_optional_child( if let Some(enabled) = enabled { state.enabled = enabled; } - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); })?; wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await } @@ -3794,8 +3829,7 @@ async fn drain_child_to_state( if let Some(enabled) = enabled { state.enabled = enabled; } - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); state.last_exit = Some(exit_report.clone()); })?; record_terminal( @@ -3906,15 +3940,26 @@ fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), }) } -fn set_running(snapshot: &SharedSnapshot, pid: Option) -> Result<(), SuperviseError> { +fn set_running(snapshot: &SharedSnapshot, child: &SupervisedChild) -> Result<(), SuperviseError> { update_snapshot(snapshot, None, |state| { state.state = ModuleState::Running; state.enabled = true; state.process_alive = true; - state.pid = pid; + state.pid = child.id(); + state.spawned_at_ms = Some(child.spawned_at_ms); + state.spawned_from = Some(child.spawned_from.clone()); + state.spawned_file_identity = child.spawned_file_identity; }) } +fn clear_current_process_facts(state: &mut SupervisorSnapshot) { + state.process_alive = false; + state.pid = None; + state.spawned_at_ms = None; + state.spawned_from = None; + state.spawned_file_identity = None; +} + fn fail_snapshot( snapshot: &SharedSnapshot, module_id: Option<&str>, @@ -3922,8 +3967,7 @@ fn fail_snapshot( ) { if let Err(err) = update_snapshot(snapshot, module_id, |state| { state.state = ModuleState::Failed; - state.process_alive = false; - state.pid = None; + clear_current_process_facts(state); if let Some(last_exit) = last_exit { state.last_exit = Some(last_exit); } diff --git a/crates/subc-core/tests/supervision.rs b/crates/subc-core/tests/supervision.rs index be8a1739..420f9c7c 100644 --- a/crates/subc-core/tests/supervision.rs +++ b/crates/subc-core/tests/supervision.rs @@ -55,6 +55,69 @@ async fn spawn_registers_stub_and_reports_running() { module.stop().await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawn_records_exact_process_facts() { + let server = TestServer::start().await; + let supervisor = supervisor(&server, 1, Duration::from_millis(10)); + let module_id = "fake-aft-spawn-facts"; + let spec = stub_spec(&server, module_id, std::iter::empty::<(&str, &str)>()); + let expected_program = spec.program.clone(); + let before_spawn_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let module = supervisor.spawn(spec).unwrap(); + let after_spawn_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + wait_for_registration(&server.registry, module_id, Duration::from_secs(10)).await; + + let first = wait_for_status(&module, Duration::from_secs(3), |status| { + status.state == ModuleState::Running && status.live + }) + .await; + assert!(first.pid.is_some(), "running child PID must be retained"); + assert_ne!(first.spawned_at_ms, Some(0)); + assert!( + first.spawned_at_ms.unwrap() >= before_spawn_ms + && first.spawned_at_ms.unwrap() <= after_spawn_ms, + "spawn time must be captured around Supervisor::spawn: {first:?}" + ); + assert_eq!(first.spawned_from, Some(expected_program.clone())); + + let before_restart_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + module.restart(None).await.unwrap(); + let restarted = wait_for_status(&module, Duration::from_secs(5), |status| { + status.state == ModuleState::Running && status.live && status.pid != first.pid + }) + .await; + let after_restart_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + assert_ne!(restarted.pid, first.pid); + assert!( + restarted.spawned_at_ms.unwrap() >= before_restart_ms + && restarted.spawned_at_ms.unwrap() <= after_restart_ms, + "restart must replace the spawn timestamp: {restarted:?}" + ); + assert!(restarted.spawned_at_ms.unwrap() > first.spawned_at_ms.unwrap()); + assert_eq!(restarted.spawned_from, Some(expected_program)); + + module.stop().await.unwrap(); + let stopped = wait_for_status(&module, Duration::from_secs(3), |status| { + status.state == ModuleState::Stopped && !status.process_alive + }) + .await; + assert_eq!(stopped.pid, None); + assert_eq!(stopped.spawned_at_ms, None); + assert_eq!(stopped.spawned_from, None); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn crash_restarts_and_reregisters_stub() { let server = TestServer::start().await; From cf2a4a33cd07ec387c0be252d2d6ac3e4b2f0031 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:03:53 +0200 Subject: [PATCH 04/19] supervisor: pin clearing and rescan-trap invariants in tests --- crates/subc-core/src/provenance.rs | 1 + crates/subc-core/src/supervise.rs | 174 ++++++++++++++++++++++++++ crates/subc-core/tests/supervision.rs | 33 ++++- 3 files changed, 206 insertions(+), 2 deletions(-) diff --git a/crates/subc-core/src/provenance.rs b/crates/subc-core/src/provenance.rs index ec75d29a..6b974156 100644 --- a/crates/subc-core/src/provenance.rs +++ b/crates/subc-core/src/provenance.rs @@ -24,6 +24,7 @@ pub(crate) fn spawned_file_identity(path: &Path) -> Option { use std::os::unix::fs::MetadataExt; + // Followed metadata identifies the spawn-time target the supervisor executed, not a symlink name. std::fs::metadata(path) .ok() .map(|metadata| SpawnedFileIdentity { diff --git a/crates/subc-core/src/supervise.rs b/crates/subc-core/src/supervise.rs index f2ea4355..225bedc3 100644 --- a/crates/subc-core/src/supervise.rs +++ b/crates/subc-core/src/supervise.rs @@ -467,6 +467,8 @@ struct SupervisorRuntimeConfig { /// exactly when it is asked for. stderr_ring: Arc>, terminal_ring: Arc>, + #[cfg(test)] + test_seed_stale_facts_before_enable_spawn: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1010,6 +1012,8 @@ impl Supervisor { TerminalRingConfig::default(), self.daemon_started_at_ms, ))), + #[cfg(test)] + test_seed_stale_facts_before_enable_spawn: false, } } @@ -2382,6 +2386,163 @@ mod tests { "a re-added module must not retain a stale removal tombstone" ); } + + fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot { + let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled))); + update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| { + snapshot.process_alive = true; + snapshot.pid = Some(41); + snapshot.spawned_at_ms = Some(42); + snapshot.spawned_from = Some(PathBuf::from("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/spawned/module")); + snapshot.spawned_file_identity = Some(SpawnedFileIdentity { + device: 43, + inode: 44, + }); + }) + .unwrap(); + snapshot + } + + fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) { + let snapshot = lock_snapshot(snapshot).unwrap(); + assert!(!snapshot.process_alive); + assert_eq!(snapshot.pid, None); + assert_eq!(snapshot.spawned_at_ms, None); + assert_eq!(snapshot.spawned_from, None); + assert_eq!(snapshot.spawned_file_identity, None); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_enable_spawn_clears_preexisting_current_process_facts() { + let supervisor = Supervisor::default(); + let mut runtime = supervisor.runtime_config(); + runtime.test_seed_stale_facts_before_enable_spawn = true; + let snapshot = stale_process_snapshot(ModuleState::Disabled, false); + let mut child = None; + let spec = ModuleSpec { + module_id: "failed-enable-clears-facts".to_string(), + program: PathBuf::from("/definitely/missing/failed-enable-module"), + args: Vec::new(), + env: Vec::new(), + reserved: false, + reserved_prefixes: Vec::new(), + }; + + let result = set_child_enabled( + &spec, + &runtime, + &supervisor.registry, + &supervisor.process_liveness, + &snapshot, + &mut child, + true, + ) + .await; + + assert!(matches!(result, Err(SuperviseError::Spawn { .. }))); + assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed); + assert_snapshot_process_facts_cleared(&snapshot); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_reload_spawn_clears_current_process_facts() { + let supervisor = Supervisor::default(); + let mut runtime = supervisor.runtime_config(); + runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO); + let snapshot = stale_process_snapshot(ModuleState::Running, true); + let mut child = None; + let spec = ModuleSpec { + module_id: "failed-reload-clears-facts".to_string(), + program: PathBuf::from("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/unused/failed-reload-module"), + args: Vec::new(), + env: Vec::new(), + reserved: false, + reserved_prefixes: Vec::new(), + }; + + let result = handle_reload_spawn_failure( + &spec, + &runtime, + &supervisor.process_liveness, + &snapshot, + &mut child, + "forced reload spawn failure".to_string(), + ) + .await; + + assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. }))); + assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed); + assert_snapshot_process_facts_cleared(&snapshot); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() { + let supervisor = Supervisor::default(); + let snapshot = stale_process_snapshot(ModuleState::Running, true); + let module = supervisor.supervised_module( + ModuleSpec { + module_id: "drop-clears-facts".to_string(), + program: PathBuf::from("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/unused/drop-module"), + args: Vec::new(), + env: Vec::new(), + reserved: false, + reserved_prefixes: Vec::new(), + }, + supervisor.runtime_config(), + Arc::clone(&snapshot), + None, + ); + assert!(!module + .inner + .monitor + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_finished()); + + drop(module); + + assert_eq!( + lock_snapshot(&snapshot).unwrap().state, + ModuleState::Stopped + ); + assert_snapshot_process_facts_cleared(&snapshot); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn configuration_update_does_not_replace_captured_running_process_facts() { + let supervisor = Supervisor::default(); + let snapshot = stale_process_snapshot(ModuleState::Running, true); + let initial = ModuleSpec { + module_id: "rescan-preserves-spawn-facts".to_string(), + program: PathBuf::from("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/spawned/module"), + args: Vec::new(), + env: Vec::new(), + reserved: false, + reserved_prefixes: Vec::new(), + }; + let module = supervisor.supervised_module( + initial.clone(), + supervisor.runtime_config(), + snapshot, + None, + ); + let before = module.status().unwrap(); + let mut replacement = initial; + replacement.program = PathBuf::from("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/rescanned/replacement-module"); + + module + .update_configuration(replacement, HealthConfig::default(), None) + .await + .unwrap(); + + let after = module.status().unwrap(); + assert_eq!(after.pid, before.pid); + assert_eq!(after.spawned_at_ms, before.spawned_at_ms); + assert_eq!(after.spawned_from, before.spawned_from); + drop(module); + } } fn unix_ms_now() -> u64 { @@ -2937,6 +3098,19 @@ async fn set_child_enabled( state.state = ModuleState::Starting; clear_current_process_facts(state); })?; + #[cfg(test)] + if runtime.test_seed_stale_facts_before_enable_spawn { + update_snapshot(snapshot, Some(&spec.module_id), |state| { + state.process_alive = true; + state.pid = Some(41); + state.spawned_at_ms = Some(42); + state.spawned_from = Some(PathBuf::from("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/spawned/module")); + state.spawned_file_identity = Some(SpawnedFileIdentity { + device: 43, + inode: 44, + }); + })?; + } wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?; reset_restart_count(snapshot, &spec.module_id)?; process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot)); diff --git a/crates/subc-core/tests/supervision.rs b/crates/subc-core/tests/supervision.rs index 420f9c7c..426e7ef6 100644 --- a/crates/subc-core/tests/supervision.rs +++ b/crates/subc-core/tests/supervision.rs @@ -30,6 +30,12 @@ impl Deref for TestServer { } } +fn assert_current_process_facts_cleared(status: &ModuleStatus) { + assert_eq!(status.pid, None); + assert_eq!(status.spawned_at_ms, None); + assert_eq!(status.spawned_from, None); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawn_registers_stub_and_reports_running() { let server = TestServer::start().await; @@ -142,6 +148,27 @@ async fn crash_restarts_and_reregisters_stub() { module.stop().await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn crash_clears_current_process_facts_before_replacement() { + let server = TestServer::start().await; + let supervisor = supervisor(&server, 1, Duration::from_millis(250)); + let module = spawn_stub_with_env( + &server, + &supervisor, + "fake-aft-crash-clears-process-facts", + [("FAKE_AFT_CRASH_AFTER_MS", "100")], + ) + .await; + + let restarting = wait_for_status(&module, Duration::from_secs(3), |status| { + status.state == ModuleState::Restarting && !status.process_alive + }) + .await; + assert_current_process_facts_cleared(&restarting); + + module.stop().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn set_enabled_current_value_returns_false_without_state_mutation() { let server = TestServer::start().await; @@ -204,7 +231,7 @@ async fn failed_spawn_during_enable_allows_a_later_retry() { ); assert!(failed.enabled); assert!(!failed.process_alive); - assert_eq!(failed.pid, None); + assert_current_process_facts_cleared(&failed); assert!( matches!(second, Err(SuperviseError::Spawn { .. })), "second enable must retry spawning instead of returning {second:?}" @@ -261,10 +288,11 @@ async fn restart_and_reload_are_rejected_for_a_disabled_module() { // Disable it, then confirm restart and reload both refuse. let changed = module.set_enabled(false).await.unwrap(); assert!(changed, "module should transition from enabled to disabled"); - wait_for_status(&module, Duration::from_secs(3), |status| { + let disabled = wait_for_status(&module, Duration::from_secs(3), |status| { status.state == ModuleState::Disabled && !status.process_alive }) .await; + assert_current_process_facts_cleared(&disabled); let restart_err = module .restart(None) @@ -541,6 +569,7 @@ async fn clean_exit_keeps_supervision_task_alive_for_operator_restart() { }) .await; assert!(!stopped.live); + assert_current_process_facts_cleared(&stopped); // The load-bearing assertion: the supervision task must still answer // commands after the clean exit, and restart must fully revive the module. From a47b5d2d2b2190f5c938ff58a49d0d39daf01c61 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Wed, 26 Aug 2026 15:31:17 +0300 Subject: [PATCH 05/19] # control: relay declared and observed provenance without laundering --- crates/subc-control/src/lib.rs | 4 +- ...esponse_server_describe_with_counters.json | 2 +- ...sponse_supervisor_provenance_mismatch.json | 2 +- ...sponse_supervisor_provenance_reported.json | 2 +- crates/subc-control/tests/golden_json.rs | 10 +- crates/subc-core/src/bin/ck.rs | 4 +- crates/subc-core/src/bin/fake-aft-stub.rs | 33 ++- crates/subc-core/src/bootstrap.rs | 24 +- crates/subc-core/src/control.rs | 181 +++++++++-- crates/subc-core/src/provenance.rs | 2 +- crates/subc-core/src/supervise.rs | 1 - crates/subc-core/tests/closure.rs | 29 ++ crates/subc-core/tests/provenance.rs | 280 ++++++++++++++++++ 13 files changed, 536 insertions(+), 38 deletions(-) create mode 100644 crates/subc-core/tests/provenance.rs diff --git a/crates/subc-control/src/lib.rs b/crates/subc-control/src/lib.rs index 432acc40..89993765 100644 --- a/crates/subc-control/src/lib.rs +++ b/crates/subc-control/src/lib.rs @@ -237,7 +237,7 @@ pub enum ClientControlResponse { /// commit to detect that it is talking to an older build than it was /// compiled with. Absent from daemons predating the field. #[serde(default, skip_serializing_if = "Option::is_none")] - build_git_sha: Option, + build_commit: Option, /// sha256 of the workspace Cargo.lock at build time, or "unavailable". /// Answers "which dependency set" where the commit answers "which /// source"; a commit match with a digest mismatch means a rebuild @@ -444,7 +444,7 @@ pub struct SupervisorDaemonProvenance { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DaemonBuildProvenance { #[serde(default, skip_serializing_if = "Option::is_none")] - pub build_git_sha: Option, + pub build_commit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub build_lock_digest: Option, } diff --git a/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json b/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json index def86e31..1b2e5470 100644 --- a/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json +++ b/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json @@ -1,5 +1,5 @@ { - "build_git_sha": "0123456789abcdef0123456789abcdef01234567", + "build_commit": "0123456789abcdef0123456789abcdef01234567", "build_lock_digest": "9d2c0d69cd82f2151bbb2b32ab9ac9d861063ffde2f8582afe767ec7e1f2145c", "capabilities": [ "manifest_registration_v1" diff --git a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json index b3a37711..0400599a 100644 --- a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json +++ b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json @@ -1,7 +1,7 @@ { "daemon": { "daemon_build": { - "build_git_sha": "fedcba9876543210fedcba9876543210fedcba98", + "build_commit": "fedcba9876543210fedcba9876543210fedcba98", "build_lock_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, "daemon_observed": { diff --git a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json index 07fbfe27..ba672f2d 100644 --- a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json +++ b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json @@ -1,7 +1,7 @@ { "daemon": { "daemon_build": { - "build_git_sha": "0123456789abcdef0123456789abcdef01234567-dirty", + "build_commit": "0123456789abcdef0123456789abcdef01234567-dirty", "build_lock_digest": "9d2c0d69cd82f2151bbb2b32ab9ac9d861063ffde2f8582afe767ec7e1f2145c" }, "daemon_observed": { diff --git a/crates/subc-control/tests/golden_json.rs b/crates/subc-control/tests/golden_json.rs index 5dacbc5d..ed184edb 100644 --- a/crates/subc-control/tests/golden_json.rs +++ b/crates/subc-control/tests/golden_json.rs @@ -232,7 +232,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { // Absent in this fixture: pins that a daemon predating the // provenance fields serializes no key at all, which is what // lets a newer client distinguish "old daemon" from a match. - build_git_sha: None, + build_commit: None, build_lock_digest: None, capability_requirements: Vec::new(), }, @@ -259,7 +259,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { })), // Distinct values, real shapes: a 40-hex commit and a 64-hex // digest, so the pin would catch the two fields transposed. - build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), build_lock_digest: Some( "9d2c0d69cd82f2151bbb2b32ab9ac9d861063ffde2f8582afe767ec7e1f2145c".to_string(), ), @@ -370,7 +370,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { ClientControlResponse::SupervisorProvenance { daemon: SupervisorDaemonProvenance { daemon_build: DaemonBuildProvenance { - build_git_sha: Some( + build_commit: Some( "0123456789abcdef0123456789abcdef01234567-dirty".to_string(), ), build_lock_digest: Some( @@ -423,7 +423,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { ClientControlResponse::SupervisorProvenance { daemon: SupervisorDaemonProvenance { daemon_build: DaemonBuildProvenance { - build_git_sha: None, + build_commit: None, build_lock_digest: None, }, daemon_observed: DaemonObservedProcess { @@ -459,7 +459,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { ClientControlResponse::SupervisorProvenance { daemon: SupervisorDaemonProvenance { daemon_build: DaemonBuildProvenance { - build_git_sha: Some( + build_commit: Some( "fedcba9876543210fedcba9876543210fedcba98".to_string(), ), build_lock_digest: Some( diff --git a/crates/subc-core/src/bin/ck.rs b/crates/subc-core/src/bin/ck.rs index eccb218e..6a34561f 100644 --- a/crates/subc-core/src/bin/ck.rs +++ b/crates/subc-core/src/bin/ck.rs @@ -313,7 +313,7 @@ fn print_dashboard(program: &Path, snapshot: &DashboardSnapshot, subc: Option<&P print_dashboard_identity(program); let build = snapshot .describe - .get("build_git_sha") + .get("build_commit") .and_then(Value::as_str) .filter(|sha| !sha.is_empty() && *sha != "unavailable") .map(short_build_sha) @@ -2021,7 +2021,7 @@ async fn daemon(client: &mut CkClient, json_output: bool) -> Result<(), CkError> /// of the check must not read as the check passing. fn print_build_skew(describe: &Value) { let cli_sha = env!("CK_BUILD_REV"); - let daemon_sha = describe.get("build_git_sha").and_then(Value::as_str); + let daemon_sha = describe.get("build_commit").and_then(Value::as_str); match daemon_sha { None => println!( "build skew: daemon predates provenance reporting (CLI {cli_sha:.12}); unverifiable" diff --git a/crates/subc-core/src/bin/fake-aft-stub.rs b/crates/subc-core/src/bin/fake-aft-stub.rs index 7d55512e..e94e74d8 100644 --- a/crates/subc-core/src/bin/fake-aft-stub.rs +++ b/crates/subc-core/src/bin/fake-aft-stub.rs @@ -21,14 +21,16 @@ use subc_protocol::{ manifest::{ Bindings, CapabilityDeclarations, Concurrency, ExecutionMode, IdentityBinding, IdentityScope, InternalTransport, ManagementOperation, ManagementOperationKind, - ObservabilityKind, ObservabilitySurface, PipelineAppliesTo, PipelineStageKind, - ProviderRole, StorageBinding, StorageKind, StorageScope, Tool, TrustTier, + ManifestProvenance, ObservabilityKind, ObservabilitySurface, PipelineAppliesTo, + PipelineStageKind, ProviderRole, StorageBinding, StorageKind, StorageScope, Tool, + TrustTier, }, session::{ HealthStatus, ModuleControlPush, ModuleControlRequest, ModuleControlResponse, MODULE_CONTROL_OP_HEALTH_CHECK, }, ErrorBody, Flags, FrameType, ModuleHelloAckBody, ModuleHelloBody, Priority, PROTOCOL_VERSION, + SUBC_PROTOCOL_CRATE_VERSION, }; use subc_transport::{authenticate_client, connection_file, AuthError, ConnectionFileError}; use tokio::{ @@ -87,6 +89,10 @@ const FAKE_AFT_EXIT_CODE_ENV: &str = "FAKE_AFT_EXIT_CODE"; /// `{pid}` token, substituted with this process's pid, for tests that must /// distinguish which generation across a restart produced a line. const FAKE_AFT_STDERR_LINE_ENV: &str = "FAKE_AFT_STDERR_LINE"; +const FAKE_AFT_BUILD_COMMIT_ENV: &str = "FAKE_AFT_BUILD_COMMIT"; +const FAKE_AFT_BUILD_LOCK_DIGEST_ENV: &str = "FAKE_AFT_BUILD_LOCK_DIGEST"; +const FAKE_AFT_STORE_SCHEMA_VERSION_ENV: &str = "FAKE_AFT_STORE_SCHEMA_VERSION"; +const FAKE_AFT_WIRE_CRATE_VERSION_ENV: &str = "FAKE_AFT_WIRE_CRATE_VERSION"; /// Milliseconds the detached orphan writer (below) sleeps before writing /// `FAKE_AFT_ORPHAN_WRITER_LINE` to stderr. Set alongside `FAKE_AFT_EXIT_CODE` /// to reproduce a wedged pump: a child that inherits this process's stderr @@ -1302,10 +1308,31 @@ fn manifest( }, }, capabilities, - provenance: None, + provenance: manifest_provenance(), } } +fn manifest_provenance() -> Option { + let build_commit = env::var(FAKE_AFT_BUILD_COMMIT_ENV).ok(); + let build_lock_digest = env::var(FAKE_AFT_BUILD_LOCK_DIGEST_ENV).ok(); + let store_schema_version = env::var(FAKE_AFT_STORE_SCHEMA_VERSION_ENV).ok(); + let wire_crate_version = env::var(FAKE_AFT_WIRE_CRATE_VERSION_ENV).ok(); + if build_commit.is_none() + && build_lock_digest.is_none() + && store_schema_version.is_none() + && wire_crate_version.is_none() + { + return None; + } + Some(ManifestProvenance { + build_commit, + build_lock_digest, + wire_crate_version: wire_crate_version + .or_else(|| Some(SUBC_PROTOCOL_CRATE_VERSION.to_string())), + store_schema_version, + }) +} + fn provider_role(role: StubRole, concurrency: Concurrency, tools: &[String]) -> ProviderRole { match role { StubRole::ToolProvider => ProviderRole::ToolProvider { diff --git a/crates/subc-core/src/bootstrap.rs b/crates/subc-core/src/bootstrap.rs index b1512e2d..783a5264 100644 --- a/crates/subc-core/src/bootstrap.rs +++ b/crates/subc-core/src/bootstrap.rs @@ -7,7 +7,7 @@ use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, path::{Path, PathBuf}, process, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use fs4::{FileExt, TryLockError}; @@ -35,8 +35,6 @@ use std::sync::Arc; #[cfg(unix)] use std::os::unix::fs::MetadataExt; -#[cfg(unix)] -use std::time::{SystemTime, UNIX_EPOCH}; pub const DEFAULT_SUBC_PORT: u16 = 8757; pub const SUBC_PORT_ENV: &str = "SUBC_PORT"; @@ -438,6 +436,12 @@ async fn serve_bound_daemon( .map(|ms| (module.module_id.clone(), Duration::from_millis(ms))) }) .collect::>(); + let control_started_at_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX); let mut control = ControlHandler::with_forwarding(Arc::clone(®istry), forwarding) .with_process_liveness(process_liveness) .with_supervisor(supervisor_handle) @@ -445,6 +449,13 @@ async fn serve_bound_daemon( .with_storage_config(storage_config) .with_admission_facts_config(admission_facts.carrier_module_id, admission_facts.targets) .with_route_bind_relay_timeouts(route_bind_relay_timeouts) + .with_daemon_provenance( + bound.connection_info.pid, + control_started_at_ms, + std::env::current_exe().ok(), + normalized_build_provenance(env!("CK_BUILD_REV")), + normalized_build_provenance(env!("CK_BUILD_LOCK_DIGEST")), + ) .with_capability_config( configured_modules .iter() @@ -527,6 +538,13 @@ async fn serve_bound_daemon( .map_err(BootstrapError::Serve) } +fn normalized_build_provenance(value: &str) -> Option { + match value.trim() { + "" | "unavailable" => None, + value => Some(value.to_string()), + } +} + /// Find an existing daemon or atomically bind loopback TCP for this daemon. /// /// The algorithm is intentionally connect-first: an endpoint from the connection diff --git a/crates/subc-core/src/control.rs b/crates/subc-core/src/control.rs index 731870d3..599ffd44 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -9,10 +9,11 @@ use std::{ use serde::{Deserialize, Serialize}; use subc_control::{ ops, CapabilityRequirementStatus, CatalogEntry, ClientControlPush, ClientControlRequest, - ClientControlResponse, ConsumerIdentity, PollKind, RouteCloseReason, StderrCaptureState, - StderrTail, StderrTailEntry, SupervisorEntry, SupervisorHealthEntry, SupervisorRescanResult, - SupervisorRoute, SupervisorRouteConsumer, SupervisorRouteModule, TerminalEntry, - TerminalHistory, + ClientControlResponse, ConsumerIdentity, DaemonBuildProvenance, DaemonObservedProcess, + ModuleDeclaredProvenance, PollKind, RouteCloseReason, StderrCaptureState, StderrTail, + StderrTailEntry, SupervisorDaemonProvenance, SupervisorEntry, SupervisorHealthEntry, + SupervisorModuleProvenance, SupervisorObservedProcess, SupervisorRescanResult, SupervisorRoute, + SupervisorRouteConsumer, SupervisorRouteModule, TerminalEntry, TerminalHistory, }; use subc_protocol::{ error_codes, @@ -41,6 +42,7 @@ use crate::{ ModuleControlRpcCompletion, ModuleControlRpcOutcome, ModuleEndpointId, PendingModuleControlRpc, RouteBindRelayOutcome, RoutePollSnapshot, RouteRelease, }, + provenance::{spawned_file_identity, ExecutableIdentityProbe, SpawnedFileIdentity}, registry::{ChannelState, ConnectionId, Registry, RegistryError}, router::{RouteCtx, RouterError}, stderr_tail::{CaptureState, TailEntry}, @@ -79,6 +81,7 @@ const SUBC_CONTROL_OPS: &[&str] = &[ ops::SUPERVISOR_STDERR_TAIL, ops::SUPERVISOR_TERMINALS, ops::SUPERVISOR_ROUTES, + ops::SUPERVISOR_PROVENANCE, ]; const MODULE_TO_SUBC_CONTROL_OPS: &[&str] = &[MODULE_TO_SUBC_OP_CATALOG_UPDATE]; @@ -96,6 +99,32 @@ const DEFAULT_ROUTE_BIND_RELAY_TIMEOUT: Duration = Duration::from_secs(12); const DEFAULT_HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(5); const SLOW_CONTROL_DISPATCH_THRESHOLD: Duration = Duration::from_secs(1); +#[derive(Clone)] +struct DaemonProvenanceFacts { + build: DaemonBuildProvenance, + pid: Option, + started_at_ms: Option, + executable_path: Option, + executable_identity: Option, + probe: ExecutableIdentityProbe, +} + +impl Default for DaemonProvenanceFacts { + fn default() -> Self { + Self { + build: DaemonBuildProvenance { + build_commit: None, + build_lock_digest: None, + }, + pid: None, + started_at_ms: None, + executable_path: None, + executable_identity: None, + probe: ExecutableIdentityProbe::default(), + } + } +} + #[derive(Debug, Clone)] struct SupervisorRescanContext { supervisor: Supervisor, @@ -132,8 +161,11 @@ pub struct ControlHandler { connected_clients: ConnectedClients, counters: DaemonCounters, capability_evaluator: Arc, + daemon_provenance: DaemonProvenanceFacts, #[cfg(test)] control_dispatch_delay: Option, + #[cfg(test)] + provenance_probe_override: Option, } impl fmt::Debug for ControlHandler { @@ -259,8 +291,11 @@ impl ControlHandler { connected_clients: ConnectedClients::new(), counters, capability_evaluator: Arc::new(CapabilityRequirementEvaluator::new()), + daemon_provenance: DaemonProvenanceFacts::default(), #[cfg(test)] control_dispatch_delay: None, + #[cfg(test)] + provenance_probe_override: None, } } @@ -344,6 +379,35 @@ impl ControlHandler { self } + pub fn with_daemon_provenance( + mut self, + pid: u32, + started_at_ms: u64, + executable_path: Option, + build_commit: Option, + build_lock_digest: Option, + ) -> Self { + let executable_identity = executable_path.as_deref().and_then(spawned_file_identity); + self.daemon_provenance = DaemonProvenanceFacts { + build: DaemonBuildProvenance { + build_commit, + build_lock_digest, + }, + pid: Some(pid), + started_at_ms: Some(started_at_ms), + executable_path, + executable_identity, + probe: ExecutableIdentityProbe::default(), + }; + self + } + + #[cfg(test)] + fn with_provenance_probe_result(mut self, result: subc_control::RunningImageAgreement) -> Self { + self.provenance_probe_override = Some(result); + self + } + /// Install the configured module set and its reserved capability bindings. /// Bindings are configuration-scoped and may point at a provider that has not /// been installed yet, so this does not require the bound module to exist. @@ -1226,12 +1290,9 @@ impl ControlHandler { ClientControlRequest::SupervisorRoutes { module_id } => { self.handle_supervisor_routes(frame, module_id) } - // exhaustiveness arm: replaced by the real handler in the provenance daemon slice (task 4) - ClientControlRequest::SupervisorProvenance { .. } => Ok(vec![control_error_frame( - &frame, - "unknown_control_op", - "unknown client control op: supervisor.provenance", - )?]), + ClientControlRequest::SupervisorProvenance { module_id } => { + self.handle_supervisor_provenance(frame, module_id).await + } ClientControlRequest::SupervisorStderrTail { module_id, max_lines, @@ -1362,7 +1423,7 @@ impl ControlHandler { capabilities: self.subc_capabilities.as_ref().to_vec(), connected_clients: self.connected_clients.count(), counters: Some(counters), - build_git_sha: Some(env!("CK_BUILD_REV").to_string()), + build_commit: Some(env!("CK_BUILD_REV").to_string()), build_lock_digest: Some(env!("CK_BUILD_LOCK_DIGEST").to_string()), capability_requirements: self.capability_requirement_statuses(), }; @@ -2015,6 +2076,84 @@ impl ControlHandler { )?]) } + async fn handle_supervisor_provenance( + &self, + frame: Frame, + module_id: Option, + ) -> Result, RouterError> { + let mut selected = if let Some(module_id) = module_id { + let Some(module) = self.supervisor.get(&module_id) else { + return Ok(vec![control_error_frame( + &frame, + "unknown_module", + format!("module_id '{module_id}' is not supervised"), + )?]); + }; + vec![module] + } else { + self.supervisor.list() + }; + + let mut modules = Vec::with_capacity(selected.len()); + for module in selected.drain(..) { + let status = module.status().map_err(|err| { + RouterError::backend( + 0, + frame.header.corr, + format!("failed to read supervisor status: {err}"), + ) + })?; + let module_declared = self + .registry + .get_module(&status.module_id) + .map_err(|err| RouterError::backend(0, frame.header.corr, err.to_string()))? + .and_then(|registration| registration.manifest.provenance) + .map(|build| ModuleDeclaredProvenance::Reported { build }) + .unwrap_or(ModuleDeclaredProvenance::Unverifiable); + #[cfg(test)] + let running_image = match &self.provenance_probe_override { + Some(result) => result.clone(), + None => module.running_image_agreement().await, + }; + #[cfg(not(test))] + let running_image = module.running_image_agreement().await; + modules.push(SupervisorModuleProvenance { + module_id: status.module_id, + module_declared, + daemon_observed: SupervisorObservedProcess { + pid: status.pid, + spawned_at_ms: status.spawned_at_ms, + spawned_from: status.spawned_from, + running_image, + }, + }); + } + modules.sort_by(|left, right| left.module_id.cmp(&right.module_id)); + + let daemon = SupervisorDaemonProvenance { + daemon_build: self.daemon_provenance.build.clone(), + daemon_observed: DaemonObservedProcess { + pid: self.daemon_provenance.pid, + started_at_ms: self.daemon_provenance.started_at_ms, + running_image: self + .daemon_provenance + .probe + .observe( + self.daemon_provenance.pid, + self.daemon_provenance.executable_path.as_deref(), + self.daemon_provenance.executable_identity, + ) + .await, + }, + }; + let response = ClientControlResponse::SupervisorProvenance { daemon, modules }; + Ok(vec![control_response_body_frame( + &frame, + &response, + "ClientControlResponse::SupervisorProvenance", + )?]) + } + fn handle_supervisor_health(&self, frame: Frame) -> Result, RouterError> { self.refresh_capability_requirements(); let generation = self @@ -6630,7 +6769,7 @@ mod tests { } #[tokio::test] - async fn supervisor_provenance_is_refused_until_handler_lands() { + async fn supervisor_provenance_rejects_unknown_exact_module() { let handler = ControlHandler::default(); let (ctx, _rx) = route_ctx(ConnectionId::new(79)); let request = Frame::build( @@ -6639,7 +6778,7 @@ mod tests { 0, 0, 57, - br#"{"op":"supervisor.provenance"}"#.to_vec(), + br#"{"op":"supervisor.provenance","module_id":"missing"}"#.to_vec(), ) .unwrap(); @@ -6649,11 +6788,17 @@ mod tests { assert_eq!(response[0].header.ty, FrameType::Error); assert_eq!(response[0].header.corr, 57); let error = parse_error(&response[0]); - assert_eq!(error["code"], "unknown_control_op"); - assert_eq!( - error["message"], - "unknown client control op: supervisor.provenance" - ); + assert_eq!(error["code"], "unknown_module"); + assert_eq!(error["message"], "module_id 'missing' is not supervised"); + } + + #[test] + fn provenance_probe_override_keeps_handler_tests_deterministic() { + let expected = subc_control::RunningImageAgreement::Unavailable { + reason: subc_control::RunningImageUnavailableReason::HashFailed, + }; + let handler = ControlHandler::default().with_provenance_probe_result(expected.clone()); + assert_eq!(handler.provenance_probe_override, Some(expected)); } #[tokio::test] diff --git a/crates/subc-core/src/provenance.rs b/crates/subc-core/src/provenance.rs index 6b974156..4d9d76c7 100644 --- a/crates/subc-core/src/provenance.rs +++ b/crates/subc-core/src/provenance.rs @@ -91,7 +91,7 @@ impl ExecutableIdentityProbe { #[cfg(not(any(target_os = "linux", target_os = "macos")))] { - let _ = (pid, spawned_from, spawned_identity); + let _ = (pid, spawned_from, _spawned_identity); unavailable(RunningImageUnavailableReason::UnsupportedPlatform) } } diff --git a/crates/subc-core/src/supervise.rs b/crates/subc-core/src/supervise.rs index 225bedc3..a5bba216 100644 --- a/crates/subc-core/src/supervise.rs +++ b/crates/subc-core/src/supervise.rs @@ -1223,7 +1223,6 @@ impl SupervisedModule { }) } - #[allow(dead_code)] pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement { let snapshot = match lock_snapshot(&self.inner.snapshot) { Ok(snapshot) => snapshot.clone(), diff --git a/crates/subc-core/tests/closure.rs b/crates/subc-core/tests/closure.rs index ca09fde3..bfb2ca95 100644 --- a/crates/subc-core/tests/closure.rs +++ b/crates/subc-core/tests/closure.rs @@ -84,6 +84,7 @@ async fn foreseeable_modules_close_over_existing_control_primitives() { assert_server_describe_uses_only_thin_core_ops(&server, &mut used_channel0_ops).await; assert_supervisor_routes_reads_the_empty_forwarding_table(&server, &mut used_channel0_ops) .await; + assert_supervisor_provenance_reads_empty_supervisor(&server, &mut used_channel0_ops).await; let embedding_payload = embedding_payload(); modules.push( @@ -389,6 +390,33 @@ async fn assert_supervisor_routes_reads_the_empty_forwarding_table( } } +async fn assert_supervisor_provenance_reads_empty_supervisor( + server: &TestServer, + used_channel0_ops: &mut BTreeSet<&'static str>, +) { + let mut client = connect_authed_client(&server.connection_file_path) + .await + .unwrap(); + let response = control_round_trip( + &mut client, + 12, + ClientControlRequest::SupervisorProvenance { module_id: None }, + ops::SUPERVISOR_PROVENANCE, + used_channel0_ops, + ) + .await; + match response { + ClientControlResponse::SupervisorProvenance { daemon, modules } => { + assert!(modules.is_empty()); + assert!(matches!( + daemon.daemon_observed.running_image, + subc_control::RunningImageAgreement::Unavailable { .. } + )); + } + other => panic!("unexpected supervisor.provenance response: {other:?}"), + } +} + async fn assert_catalog_lists_every_archetype_with_only_thin_core_ops( server: &TestServer, used_channel0_ops: &mut BTreeSet<&'static str>, @@ -729,6 +757,7 @@ fn thin_core_ops() -> BTreeSet<&'static str> { ops::SUPERVISOR_STDERR_TAIL, ops::SUPERVISOR_TERMINALS, ops::SUPERVISOR_ROUTES, + ops::SUPERVISOR_PROVENANCE, ]) } diff --git a/crates/subc-core/tests/provenance.rs b/crates/subc-core/tests/provenance.rs new file mode 100644 index 00000000..b300f338 --- /dev/null +++ b/crates/subc-core/tests/provenance.rs @@ -0,0 +1,280 @@ +use std::{ + fs, + path::PathBuf, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; + +#[cfg(target_os = "windows")] +use subc_control::RunningImageUnavailableReason; +use subc_control::{ + ClientControlRequest, ClientControlResponse, ModuleDeclaredProvenance, RunningImageAgreement, + RunningImageEvidence, +}; +use subc_core::{ + read_frame, write_frame, Frame, ModuleSpec, RestartPolicy, Supervisor, SupervisorHandle, + SupervisorProcessLiveness, +}; +use subc_protocol::{Flags, FrameType, Priority, SUBC_PROTOCOL_CRATE_VERSION}; +use tokio::{ + io::AsyncWriteExt, + time::{sleep, timeout, Instant}, +}; + +mod common; +use common::{ + connect_authed_client, start_test_daemon_with_process_liveness_and_supervisor, TestDaemon, +}; + +const READ_TIMEOUT: Duration = Duration::from_secs(2); +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn supervisor_provenance_reports_declared_and_observed_module_facts() { + let process_liveness = Arc::new(SupervisorProcessLiveness::new()); + let supervisor_handle = SupervisorHandle::new(); + let daemon = start_test_daemon_with_process_liveness_and_supervisor( + "provenance-reported", + process_liveness.clone(), + supervisor_handle.clone(), + ) + .await; + let supervisor = Supervisor::new(Arc::clone(&daemon.registry), RestartPolicy::default()) + .with_process_liveness(process_liveness) + .with_handle(supervisor_handle) + .with_drain_timeout(Duration::from_millis(25)) + .with_connection_file_path(daemon.connection_file_path.clone()); + let module = supervisor + .spawn(stub_spec( + "provenance-reported", + vec![ + ( + "FAKE_AFT_BUILD_COMMIT", + "ffffffffffffffffffffffffffffffffffffffff", + ), + ( + "FAKE_AFT_BUILD_LOCK_DIGEST", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + ("FAKE_AFT_STORE_SCHEMA_VERSION", "7"), + ], + )) + .unwrap(); + wait_for_registration(&daemon, "provenance-reported").await; + + let response = provenance_request(&daemon, 1, Some("provenance-reported")).await; + let ClientControlResponse::SupervisorProvenance { + daemon: observed_daemon, + modules, + } = response + else { + panic!("supervisor.provenance must return a provenance response"); + }; + assert_eq!(modules.len(), 1); + let observed = &modules[0]; + let ModuleDeclaredProvenance::Reported { build } = &observed.module_declared else { + panic!("the provenance block must remain a module declaration"); + }; + assert_eq!( + build.build_commit.as_deref(), + Some("ffffffffffffffffffffffffffffffffffffffff") + ); + assert_eq!( + build.build_lock_digest.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + assert_eq!( + build.wire_crate_version.as_deref(), + Some(SUBC_PROTOCOL_CRATE_VERSION) + ); + assert_eq!(build.store_schema_version.as_deref(), Some("7")); + let status = module.status().unwrap(); + assert_eq!(observed.daemon_observed.pid, status.pid); + assert_eq!( + observed.daemon_observed.spawned_from, + Some(PathBuf::from(env!("CARGO_BIN_EXE_fake-aft-stub"))) + ); + assert!(observed.daemon_observed.spawned_at_ms.unwrap_or_default() > 0); + assert_running_image_matches(&observed.daemon_observed.running_image); + let rendered = serde_json::to_string(&observed_daemon).unwrap(); + assert!(!rendered.contains("ffffffffffffffffffffffffffffffffffffffff")); + module.stop().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn supervisor_provenance_marks_absent_manifest_block_unverifiable() { + let process_liveness = Arc::new(SupervisorProcessLiveness::new()); + let supervisor_handle = SupervisorHandle::new(); + let daemon = start_test_daemon_with_process_liveness_and_supervisor( + "provenance-unverifiable", + process_liveness.clone(), + supervisor_handle.clone(), + ) + .await; + let supervisor = Supervisor::new(Arc::clone(&daemon.registry), RestartPolicy::default()) + .with_process_liveness(process_liveness) + .with_handle(supervisor_handle) + .with_drain_timeout(Duration::from_millis(25)) + .with_connection_file_path(daemon.connection_file_path.clone()); + let module = supervisor + .spawn(stub_spec("provenance-unverifiable", Vec::new())) + .unwrap(); + wait_for_registration(&daemon, "provenance-unverifiable").await; + + let response = provenance_request(&daemon, 2, Some("provenance-unverifiable")).await; + let ClientControlResponse::SupervisorProvenance { modules, .. } = response else { + panic!("supervisor.provenance must return a provenance response"); + }; + assert!(matches!( + modules[0].module_declared, + ModuleDeclaredProvenance::Unverifiable + )); + module.stop().await.unwrap(); +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn supervisor_provenance_detects_replaced_executable_image() { + let process_liveness = Arc::new(SupervisorProcessLiveness::new()); + let supervisor_handle = SupervisorHandle::new(); + let daemon = start_test_daemon_with_process_liveness_and_supervisor( + "provenance-replacement", + process_liveness.clone(), + supervisor_handle.clone(), + ) + .await; + let supervisor = Supervisor::new(Arc::clone(&daemon.registry), RestartPolicy::default()) + .with_process_liveness(process_liveness) + .with_handle(supervisor_handle) + .with_drain_timeout(Duration::from_millis(25)) + .with_connection_file_path(daemon.connection_file_path.clone()); + let temp_dir = unique_temp_dir("provenance-replacement"); + fs::create_dir_all(&temp_dir).unwrap(); + let copied_stub = temp_dir.join("fake-aft-stub"); + fs::copy(env!("CARGO_BIN_EXE_fake-aft-stub"), &copied_stub).unwrap(); + let module = supervisor + .spawn(ModuleSpec { + module_id: "provenance-replacement".to_string(), + program: copied_stub.clone(), + args: Vec::new(), + env: vec![( + "FAKE_AFT_MODULE_ID".to_string(), + "provenance-replacement".to_string(), + )], + reserved: false, + reserved_prefixes: Vec::new(), + }) + .unwrap(); + wait_for_registration(&daemon, "provenance-replacement").await; + let replacement = temp_dir.join("replacement"); + fs::copy(env!("CARGO_BIN_EXE_ck"), &replacement).unwrap(); + fs::rename(&replacement, &copied_stub).unwrap(); + + let response = provenance_request(&daemon, 3, Some("provenance-replacement")).await; + let ClientControlResponse::SupervisorProvenance { modules, .. } = response else { + panic!("supervisor.provenance must return a provenance response"); + }; + assert!(matches!( + modules[0].daemon_observed.running_image, + RunningImageAgreement::Mismatch { .. } + )); + module.stop().await.unwrap(); + fs::remove_dir_all(temp_dir).unwrap(); +} + +fn stub_spec(module_id: &str, env: Vec<(&str, &str)>) -> ModuleSpec { + ModuleSpec { + module_id: module_id.to_string(), + program: PathBuf::from(env!("CARGO_BIN_EXE_fake-aft-stub")), + args: Vec::new(), + env: std::iter::once(("FAKE_AFT_MODULE_ID".to_string(), module_id.to_string())) + .chain( + env.into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())), + ) + .collect(), + reserved: false, + reserved_prefixes: Vec::new(), + } +} + +async fn wait_for_registration(daemon: &TestDaemon, module_id: &str) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if daemon.registry.get_module(module_id).unwrap().is_some() { + return; + } + assert!( + Instant::now() < deadline, + "module {module_id} did not register" + ); + sleep(Duration::from_millis(10)).await; + } +} + +async fn provenance_request( + daemon: &TestDaemon, + corr: u64, + module_id: Option<&str>, +) -> ClientControlResponse { + let mut client = connect_authed_client(&daemon.connection_file_path) + .await + .unwrap(); + let body = serde_json::to_vec(&ClientControlRequest::SupervisorProvenance { + module_id: module_id.map(str::to_string), + }) + .unwrap(); + let request = Frame::build( + FrameType::Request, + Flags::new(false, Priority::Passive, false), + 0, + 0, + corr, + body, + ) + .unwrap(); + write_frame(&mut client, &request).await.unwrap(); + client.flush().await.unwrap(); + let frame = timeout(READ_TIMEOUT, read_frame(&mut client)) + .await + .unwrap() + .unwrap() + .expect("server closed before provenance response"); + assert_eq!(frame.header.ty, FrameType::Response); + serde_json::from_slice(&frame.body).unwrap() +} + +fn assert_running_image_matches(result: &RunningImageAgreement) { + #[cfg(target_os = "linux")] + assert!(matches!( + result, + RunningImageAgreement::Match { + evidence: RunningImageEvidence::LinuxProcSha256 { .. } + } + )); + #[cfg(target_os = "macos")] + assert!(matches!( + result, + RunningImageAgreement::Match { + evidence: RunningImageEvidence::MacosSpawnInode { .. } + } + )); + #[cfg(target_os = "windows")] + assert!(matches!( + result, + RunningImageAgreement::Unavailable { + reason: RunningImageUnavailableReason::UnsupportedPlatform + } + )); +} + +fn unique_temp_dir(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "subc-{label}-{}-{}", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )) +} From 28cecc2a67dbb50f5bf61fcd90cdb64e899aa413 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:28:23 +0200 Subject: [PATCH 06/19] provenance: align build identity on the shipped build_git_sha vocabulary The manifest block introduced earlier in this branch named its commit field build_commit, while master already ships build_git_sha and build_lock_digest on server.describe, consumed by ck's dashboard and its build-skew detector. Two names for one fact inside a single response is a reader trap, and renaming the shipped field would have broken an older ck against a newer daemon by making the skew check fail open. Align the unreleased name onto the shipped vocabulary instead. CONSUMER-IMPACT: Renames ManifestProvenance.build_commit to build_git_sha in subc-protocol. The field is unreleased -- it was introduced earlier in this same branch and has never appeared in a published crate -- so no out-of-tree consumer can have decoded or constructed it under the old name, and there is no deprecation window to honour. Net effect against master is unchanged from the introducing commit: one additive optional block whose fields old daemons ignore during manifest decode, with Rust literal constructors of ModuleManifest still needing the source update that commit described. Wire field names on server.describe and the daemon provenance record are untouched by this commit and remain byte-identical to master. --- crates/subc-client-rs/src/lib.rs | 12 ++++++------ crates/subc-control/src/lib.rs | 4 ++-- ...rol_response_server_describe_with_counters.json | 2 +- ...ol_response_supervisor_provenance_mismatch.json | 4 ++-- ...ol_response_supervisor_provenance_reported.json | 4 ++-- crates/subc-control/tests/golden_json.rs | 14 +++++++------- crates/subc-core/src/bin/ck.rs | 4 ++-- crates/subc-core/src/bin/fake-aft-stub.rs | 2 +- crates/subc-core/src/control.rs | 8 ++++---- crates/subc-core/tests/catalog_update.rs | 2 +- crates/subc-core/tests/provenance.rs | 2 +- crates/subc-mcp/src/main.rs | 4 ++-- crates/subc-protocol/src/manifest.rs | 2 +- .../golden/module_hello_body_with_provenance.json | 2 +- crates/subc-protocol/tests/golden_json.rs | 10 +++++----- 15 files changed, 38 insertions(+), 38 deletions(-) diff --git a/crates/subc-client-rs/src/lib.rs b/crates/subc-client-rs/src/lib.rs index 918670cf..a1b07bdb 100644 --- a/crates/subc-client-rs/src/lib.rs +++ b/crates/subc-client-rs/src/lib.rs @@ -52,12 +52,12 @@ pub use subc_protocol::{ }; pub fn build_provenance( - build_commit: Option<&str>, + build_git_sha: Option<&str>, build_lock_digest: Option<&str>, store_schema_version: Option<&str>, ) -> ManifestProvenance { ManifestProvenance { - build_commit: normalize_provenance_fact(build_commit), + build_git_sha: normalize_provenance_fact(build_git_sha), build_lock_digest: normalize_provenance_fact(build_lock_digest), wire_crate_version: Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()), store_schema_version: normalize_provenance_fact(store_schema_version), @@ -80,7 +80,7 @@ fn build_provenance_normalizes_clean_build_facts() { assert_eq!( provenance, ManifestProvenance { - build_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()), build_lock_digest: Some("lock-digest".to_string()), wire_crate_version: Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()), store_schema_version: Some("schema-v3".to_string()), @@ -97,7 +97,7 @@ fn build_provenance_preserves_a_dirty_revision_verbatim() { ); assert_eq!( - provenance.build_commit, + provenance.build_git_sha, Some("0123456789abcdef0123456789abcdef01234567-dirty".to_string()) ); assert_eq!( @@ -110,7 +110,7 @@ fn build_provenance_preserves_a_dirty_revision_verbatim() { fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() { let provenance = build_provenance(Some("unavailable"), Some("lock-digest"), None); - assert_eq!(provenance.build_commit, None); + assert_eq!(provenance.build_git_sha, None); assert_eq!( provenance.build_lock_digest, Some("lock-digest".to_string()) @@ -125,7 +125,7 @@ fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() { fn build_provenance_omits_fully_unavailable_inputs() { let provenance = build_provenance(None, Some(" unavailable "), Some(" ")); - assert_eq!(provenance.build_commit, None); + assert_eq!(provenance.build_git_sha, None); assert_eq!(provenance.build_lock_digest, None); assert_eq!(provenance.store_schema_version, None); assert_eq!( diff --git a/crates/subc-control/src/lib.rs b/crates/subc-control/src/lib.rs index 89993765..432acc40 100644 --- a/crates/subc-control/src/lib.rs +++ b/crates/subc-control/src/lib.rs @@ -237,7 +237,7 @@ pub enum ClientControlResponse { /// commit to detect that it is talking to an older build than it was /// compiled with. Absent from daemons predating the field. #[serde(default, skip_serializing_if = "Option::is_none")] - build_commit: Option, + build_git_sha: Option, /// sha256 of the workspace Cargo.lock at build time, or "unavailable". /// Answers "which dependency set" where the commit answers "which /// source"; a commit match with a digest mismatch means a rebuild @@ -444,7 +444,7 @@ pub struct SupervisorDaemonProvenance { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DaemonBuildProvenance { #[serde(default, skip_serializing_if = "Option::is_none")] - pub build_commit: Option, + pub build_git_sha: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub build_lock_digest: Option, } diff --git a/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json b/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json index 1b2e5470..def86e31 100644 --- a/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json +++ b/crates/subc-control/tests/golden/client_control_response_server_describe_with_counters.json @@ -1,5 +1,5 @@ { - "build_commit": "0123456789abcdef0123456789abcdef01234567", + "build_git_sha": "0123456789abcdef0123456789abcdef01234567", "build_lock_digest": "9d2c0d69cd82f2151bbb2b32ab9ac9d861063ffde2f8582afe767ec7e1f2145c", "capabilities": [ "manifest_registration_v1" diff --git a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json index 0400599a..b065fef6 100644 --- a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json +++ b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_mismatch.json @@ -1,7 +1,7 @@ { "daemon": { "daemon_build": { - "build_commit": "fedcba9876543210fedcba9876543210fedcba98", + "build_git_sha": "fedcba9876543210fedcba9876543210fedcba98", "build_lock_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, "daemon_observed": { @@ -36,7 +36,7 @@ }, "module_declared": { "build": { - "build_commit": "fedcba9876543210fedcba9876543210fedcba98-dirty", + "build_git_sha": "fedcba9876543210fedcba9876543210fedcba98-dirty", "build_lock_digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "store_schema_version": "3", "wire_crate_version": "0.13.0" diff --git a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json index ba672f2d..835bf869 100644 --- a/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json +++ b/crates/subc-control/tests/golden/client_control_response_supervisor_provenance_reported.json @@ -1,7 +1,7 @@ { "daemon": { "daemon_build": { - "build_commit": "0123456789abcdef0123456789abcdef01234567-dirty", + "build_git_sha": "0123456789abcdef0123456789abcdef01234567-dirty", "build_lock_digest": "9d2c0d69cd82f2151bbb2b32ab9ac9d861063ffde2f8582afe767ec7e1f2145c" }, "daemon_observed": { @@ -32,7 +32,7 @@ }, "module_declared": { "build": { - "build_commit": "0123456789abcdef0123456789abcdef01234567", + "build_git_sha": "0123456789abcdef0123456789abcdef01234567", "build_lock_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "store_schema_version": "2", "wire_crate_version": "0.13.0" diff --git a/crates/subc-control/tests/golden_json.rs b/crates/subc-control/tests/golden_json.rs index ed184edb..0e8f15ba 100644 --- a/crates/subc-control/tests/golden_json.rs +++ b/crates/subc-control/tests/golden_json.rs @@ -232,7 +232,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { // Absent in this fixture: pins that a daemon predating the // provenance fields serializes no key at all, which is what // lets a newer client distinguish "old daemon" from a match. - build_commit: None, + build_git_sha: None, build_lock_digest: None, capability_requirements: Vec::new(), }, @@ -259,7 +259,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { })), // Distinct values, real shapes: a 40-hex commit and a 64-hex // digest, so the pin would catch the two fields transposed. - build_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()), build_lock_digest: Some( "9d2c0d69cd82f2151bbb2b32ab9ac9d861063ffde2f8582afe767ec7e1f2145c".to_string(), ), @@ -370,7 +370,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { ClientControlResponse::SupervisorProvenance { daemon: SupervisorDaemonProvenance { daemon_build: DaemonBuildProvenance { - build_commit: Some( + build_git_sha: Some( "0123456789abcdef0123456789abcdef01234567-dirty".to_string(), ), build_lock_digest: Some( @@ -393,7 +393,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { module_id: "aft".to_string(), module_declared: ModuleDeclaredProvenance::Reported { build: ManifestProvenance { - build_commit: Some( + build_git_sha: Some( "0123456789abcdef0123456789abcdef01234567".to_string(), ), build_lock_digest: Some( @@ -423,7 +423,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { ClientControlResponse::SupervisorProvenance { daemon: SupervisorDaemonProvenance { daemon_build: DaemonBuildProvenance { - build_commit: None, + build_git_sha: None, build_lock_digest: None, }, daemon_observed: DaemonObservedProcess { @@ -459,7 +459,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { ClientControlResponse::SupervisorProvenance { daemon: SupervisorDaemonProvenance { daemon_build: DaemonBuildProvenance { - build_commit: Some( + build_git_sha: Some( "fedcba9876543210fedcba9876543210fedcba98".to_string(), ), build_lock_digest: Some( @@ -482,7 +482,7 @@ fn client_control_responses() -> Vec<(&'static str, ClientControlResponse)> { module_id: "mcp".to_string(), module_declared: ModuleDeclaredProvenance::Reported { build: ManifestProvenance { - build_commit: Some( + build_git_sha: Some( "fedcba9876543210fedcba9876543210fedcba98-dirty".to_string(), ), build_lock_digest: Some( diff --git a/crates/subc-core/src/bin/ck.rs b/crates/subc-core/src/bin/ck.rs index 6a34561f..eccb218e 100644 --- a/crates/subc-core/src/bin/ck.rs +++ b/crates/subc-core/src/bin/ck.rs @@ -313,7 +313,7 @@ fn print_dashboard(program: &Path, snapshot: &DashboardSnapshot, subc: Option<&P print_dashboard_identity(program); let build = snapshot .describe - .get("build_commit") + .get("build_git_sha") .and_then(Value::as_str) .filter(|sha| !sha.is_empty() && *sha != "unavailable") .map(short_build_sha) @@ -2021,7 +2021,7 @@ async fn daemon(client: &mut CkClient, json_output: bool) -> Result<(), CkError> /// of the check must not read as the check passing. fn print_build_skew(describe: &Value) { let cli_sha = env!("CK_BUILD_REV"); - let daemon_sha = describe.get("build_commit").and_then(Value::as_str); + let daemon_sha = describe.get("build_git_sha").and_then(Value::as_str); match daemon_sha { None => println!( "build skew: daemon predates provenance reporting (CLI {cli_sha:.12}); unverifiable" diff --git a/crates/subc-core/src/bin/fake-aft-stub.rs b/crates/subc-core/src/bin/fake-aft-stub.rs index e94e74d8..975d3311 100644 --- a/crates/subc-core/src/bin/fake-aft-stub.rs +++ b/crates/subc-core/src/bin/fake-aft-stub.rs @@ -1325,7 +1325,7 @@ fn manifest_provenance() -> Option { return None; } Some(ManifestProvenance { - build_commit, + build_git_sha: build_commit, build_lock_digest, wire_crate_version: wire_crate_version .or_else(|| Some(SUBC_PROTOCOL_CRATE_VERSION.to_string())), diff --git a/crates/subc-core/src/control.rs b/crates/subc-core/src/control.rs index 599ffd44..2018130e 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -113,7 +113,7 @@ impl Default for DaemonProvenanceFacts { fn default() -> Self { Self { build: DaemonBuildProvenance { - build_commit: None, + build_git_sha: None, build_lock_digest: None, }, pid: None, @@ -384,13 +384,13 @@ impl ControlHandler { pid: u32, started_at_ms: u64, executable_path: Option, - build_commit: Option, + build_git_sha: Option, build_lock_digest: Option, ) -> Self { let executable_identity = executable_path.as_deref().and_then(spawned_file_identity); self.daemon_provenance = DaemonProvenanceFacts { build: DaemonBuildProvenance { - build_commit, + build_git_sha, build_lock_digest, }, pid: Some(pid), @@ -1423,7 +1423,7 @@ impl ControlHandler { capabilities: self.subc_capabilities.as_ref().to_vec(), connected_clients: self.connected_clients.count(), counters: Some(counters), - build_commit: Some(env!("CK_BUILD_REV").to_string()), + build_git_sha: Some(env!("CK_BUILD_REV").to_string()), build_lock_digest: Some(env!("CK_BUILD_LOCK_DIGEST").to_string()), capability_requirements: self.capability_requirement_statuses(), }; diff --git a/crates/subc-core/tests/catalog_update.rs b/crates/subc-core/tests/catalog_update.rs index b7a62329..63f76aeb 100644 --- a/crates/subc-core/tests/catalog_update.rs +++ b/crates/subc-core/tests/catalog_update.rs @@ -73,7 +73,7 @@ async fn catalog_update_refreshes_catalog_without_disrupting_bound_routes() { let module_id = "catalog-update-provider"; let mut module = connect_endpoint(&server, "module").await; let provenance = ManifestProvenance { - build_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()), build_lock_digest: Some("lock-digest".to_string()), wire_crate_version: Some("0.13.0".to_string()), store_schema_version: Some("3".to_string()), diff --git a/crates/subc-core/tests/provenance.rs b/crates/subc-core/tests/provenance.rs index b300f338..4098892c 100644 --- a/crates/subc-core/tests/provenance.rs +++ b/crates/subc-core/tests/provenance.rs @@ -79,7 +79,7 @@ async fn supervisor_provenance_reports_declared_and_observed_module_facts() { panic!("the provenance block must remain a module declaration"); }; assert_eq!( - build.build_commit.as_deref(), + build.build_git_sha.as_deref(), Some("ffffffffffffffffffffffffffffffffffffffff") ); assert_eq!( diff --git a/crates/subc-mcp/src/main.rs b/crates/subc-mcp/src/main.rs index 3a34c4d5..b232683b 100644 --- a/crates/subc-mcp/src/main.rs +++ b/crates/subc-mcp/src/main.rs @@ -1952,7 +1952,7 @@ fn manifest_output_always_includes_an_empty_runtime_computed_array() { fn manifest_output_keeps_provenance_in_the_static_manifest_object() { let mut manifest = supervision_manifest(MANIFEST_MODULE_ID.to_string()); manifest.provenance = Some(subc_protocol::manifest::ManifestProvenance { - build_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()), build_lock_digest: None, wire_crate_version: Some("0.13.0".to_string()), store_schema_version: None, @@ -1962,7 +1962,7 @@ fn manifest_output_keeps_provenance_in_the_static_manifest_object() { assert_eq!( value["provenance"], serde_json::json!({ - "build_commit": "0123456789abcdef0123456789abcdef01234567", + "build_git_sha": "0123456789abcdef0123456789abcdef01234567", "wire_crate_version": "0.13.0" }) ); diff --git a/crates/subc-protocol/src/manifest.rs b/crates/subc-protocol/src/manifest.rs index 9dae2559..c35cf995 100644 --- a/crates/subc-protocol/src/manifest.rs +++ b/crates/subc-protocol/src/manifest.rs @@ -95,7 +95,7 @@ pub struct CapabilityDeclarations { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ManifestProvenance { #[serde(default, skip_serializing_if = "Option::is_none")] - pub build_commit: Option, + pub build_git_sha: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub build_lock_digest: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/subc-protocol/tests/golden/module_hello_body_with_provenance.json b/crates/subc-protocol/tests/golden/module_hello_body_with_provenance.json index d3f86e90..1d838085 100644 --- a/crates/subc-protocol/tests/golden/module_hello_body_with_provenance.json +++ b/crates/subc-protocol/tests/golden/module_hello_body_with_provenance.json @@ -25,7 +25,7 @@ "module_version": "1.2.3", "protocol_ver": 2, "provenance": { - "build_commit": "0123456789abcdef0123456789abcdef01234567-dirty", + "build_git_sha": "0123456789abcdef0123456789abcdef01234567-dirty", "build_lock_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", "store_schema_version": "42", "wire_crate_version": "0.13.0" diff --git a/crates/subc-protocol/tests/golden_json.rs b/crates/subc-protocol/tests/golden_json.rs index e130b9a4..7e14c7e3 100644 --- a/crates/subc-protocol/tests/golden_json.rs +++ b/crates/subc-protocol/tests/golden_json.rs @@ -164,7 +164,7 @@ fn manifest_provenance_round_trips_all_facts_through_the_real_manifest_deseriali assert_eq!( encoded["manifest"]["provenance"], serde_json::json!({ - "build_commit": "0123456789abcdef0123456789abcdef01234567-dirty", + "build_git_sha": "0123456789abcdef0123456789abcdef01234567-dirty", "build_lock_digest": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", "wire_crate_version": "0.13.0", "store_schema_version": "42" @@ -177,19 +177,19 @@ fn manifest_provenance_round_trips_all_facts_through_the_real_manifest_deseriali #[test] fn manifest_provenance_omits_each_unavailable_fact_independently() { for field in [ - "build_commit", + "build_git_sha", "build_lock_digest", "wire_crate_version", "store_schema_version", ] { let mut provenance = ManifestProvenance { - build_commit: Some("commit".to_string()), + build_git_sha: Some("commit".to_string()), build_lock_digest: Some("lock".to_string()), wire_crate_version: Some("wire".to_string()), store_schema_version: Some("schema".to_string()), }; match field { - "build_commit" => provenance.build_commit = None, + "build_git_sha" => provenance.build_git_sha = None, "build_lock_digest" => provenance.build_lock_digest = None, "wire_crate_version" => provenance.wire_crate_version = None, "store_schema_version" => provenance.store_schema_version = None, @@ -376,7 +376,7 @@ fn module_hello_body() -> ModuleHelloBody { fn module_hello_body_with_provenance() -> ModuleHelloBody { let mut hello = module_hello_body(); hello.manifest.provenance = Some(ManifestProvenance { - build_commit: Some("0123456789abcdef0123456789abcdef01234567-dirty".to_string()), + build_git_sha: Some("0123456789abcdef0123456789abcdef01234567-dirty".to_string()), build_lock_digest: Some( "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(), ), From bc1464694fac8b58f56b848946833f2f83e7d3c0 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:45:49 +0200 Subject: [PATCH 07/19] provenance: assert the full declared-field class against daemon leakage --- crates/subc-client-rs/src/lib.rs | 129 ++++++++++++++------------- crates/subc-core/src/control.rs | 2 - crates/subc-core/tests/provenance.rs | 30 +++++-- 3 files changed, 87 insertions(+), 74 deletions(-) diff --git a/crates/subc-client-rs/src/lib.rs b/crates/subc-client-rs/src/lib.rs index a1b07bdb..665a7c4c 100644 --- a/crates/subc-client-rs/src/lib.rs +++ b/crates/subc-client-rs/src/lib.rs @@ -69,70 +69,6 @@ fn normalize_provenance_fact(value: Option<&str>) -> Option { (!value.is_empty() && value != "unavailable").then(|| value.to_string()) } -#[test] -fn build_provenance_normalizes_clean_build_facts() { - let provenance = build_provenance( - Some(" 0123456789abcdef0123456789abcdef01234567 "), - Some(" lock-digest "), - Some(" schema-v3 "), - ); - - assert_eq!( - provenance, - ManifestProvenance { - build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()), - build_lock_digest: Some("lock-digest".to_string()), - wire_crate_version: Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()), - store_schema_version: Some("schema-v3".to_string()), - } - ); -} - -#[test] -fn build_provenance_preserves_a_dirty_revision_verbatim() { - let provenance = build_provenance( - Some("0123456789abcdef0123456789abcdef01234567-dirty"), - Some("lock-digest"), - None, - ); - - assert_eq!( - provenance.build_git_sha, - Some("0123456789abcdef0123456789abcdef01234567-dirty".to_string()) - ); - assert_eq!( - provenance.build_lock_digest, - Some("lock-digest".to_string()) - ); -} - -#[test] -fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() { - let provenance = build_provenance(Some("unavailable"), Some("lock-digest"), None); - - assert_eq!(provenance.build_git_sha, None); - assert_eq!( - provenance.build_lock_digest, - Some("lock-digest".to_string()) - ); - assert_eq!( - provenance.wire_crate_version, - Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()) - ); -} - -#[test] -fn build_provenance_omits_fully_unavailable_inputs() { - let provenance = build_provenance(None, Some(" unavailable "), Some(" ")); - - assert_eq!(provenance.build_git_sha, None); - assert_eq!(provenance.build_lock_digest, None); - assert_eq!(provenance.store_schema_version, None); - assert_eq!( - provenance.wire_crate_version, - Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()) - ); -} use subc_transport::{ authenticate_client, connection_file, read_frame, write_frame, AuthError, ConnectionFileError, FrameIoError, @@ -1603,6 +1539,71 @@ mod tests { use super::*; + #[test] + fn build_provenance_normalizes_clean_build_facts() { + let provenance = build_provenance( + Some(" 0123456789abcdef0123456789abcdef01234567 "), + Some(" lock-digest "), + Some(" schema-v3 "), + ); + + assert_eq!( + provenance, + ManifestProvenance { + build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + build_lock_digest: Some("lock-digest".to_string()), + wire_crate_version: Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()), + store_schema_version: Some("schema-v3".to_string()), + } + ); + } + + #[test] + fn build_provenance_preserves_a_dirty_revision_verbatim() { + let provenance = build_provenance( + Some("0123456789abcdef0123456789abcdef01234567-dirty"), + Some("lock-digest"), + None, + ); + + assert_eq!( + provenance.build_git_sha, + Some("0123456789abcdef0123456789abcdef01234567-dirty".to_string()) + ); + assert_eq!( + provenance.build_lock_digest, + Some("lock-digest".to_string()) + ); + } + + #[test] + fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() { + let provenance = build_provenance(Some("unavailable"), Some("lock-digest"), None); + + assert_eq!(provenance.build_git_sha, None); + assert_eq!( + provenance.build_lock_digest, + Some("lock-digest".to_string()) + ); + assert_eq!( + provenance.wire_crate_version, + Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()) + ); + } + + #[test] + fn build_provenance_omits_fully_unavailable_inputs() { + let provenance = build_provenance(None, Some(" unavailable "), Some(" ")); + + assert_eq!(provenance.build_git_sha, None); + assert_eq!(provenance.build_lock_digest, None); + assert_eq!(provenance.store_schema_version, None); + assert_eq!( + provenance.wire_crate_version, + Some(SUBC_PROTOCOL_CRATE_VERSION.to_string()) + ); + } + struct EchoHandler; #[async_trait] diff --git a/crates/subc-core/src/control.rs b/crates/subc-core/src/control.rs index 2018130e..b722c12e 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -2128,8 +2128,6 @@ impl ControlHandler { }, }); } - modules.sort_by(|left, right| left.module_id.cmp(&right.module_id)); - let daemon = SupervisorDaemonProvenance { daemon_build: self.daemon_provenance.build.clone(), daemon_observed: DaemonObservedProcess { diff --git a/crates/subc-core/tests/provenance.rs b/crates/subc-core/tests/provenance.rs index 4098892c..bb598b07 100644 --- a/crates/subc-core/tests/provenance.rs +++ b/crates/subc-core/tests/provenance.rs @@ -18,7 +18,7 @@ use subc_core::{ read_frame, write_frame, Frame, ModuleSpec, RestartPolicy, Supervisor, SupervisorHandle, SupervisorProcessLiveness, }; -use subc_protocol::{Flags, FrameType, Priority, SUBC_PROTOCOL_CRATE_VERSION}; +use subc_protocol::{Flags, FrameType, Priority}; use tokio::{ io::AsyncWriteExt, time::{sleep, timeout, Instant}, @@ -57,9 +57,10 @@ async fn supervisor_provenance_reports_declared_and_observed_module_facts() { ), ( "FAKE_AFT_BUILD_LOCK_DIGEST", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", ), - ("FAKE_AFT_STORE_SCHEMA_VERSION", "7"), + ("FAKE_AFT_WIRE_CRATE_VERSION", "0.0.0-forged"), + ("FAKE_AFT_STORE_SCHEMA_VERSION", "9999-forged-schema"), ], )) .unwrap(); @@ -84,13 +85,13 @@ async fn supervisor_provenance_reports_declared_and_observed_module_facts() { ); assert_eq!( build.build_lock_digest.as_deref(), - Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + Some("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") ); + assert_eq!(build.wire_crate_version.as_deref(), Some("0.0.0-forged")); assert_eq!( - build.wire_crate_version.as_deref(), - Some(SUBC_PROTOCOL_CRATE_VERSION) + build.store_schema_version.as_deref(), + Some("9999-forged-schema") ); - assert_eq!(build.store_schema_version.as_deref(), Some("7")); let status = module.status().unwrap(); assert_eq!(observed.daemon_observed.pid, status.pid); assert_eq!( @@ -100,7 +101,20 @@ async fn supervisor_provenance_reports_declared_and_observed_module_facts() { assert!(observed.daemon_observed.spawned_at_ms.unwrap_or_default() > 0); assert_running_image_matches(&observed.daemon_observed.running_image); let rendered = serde_json::to_string(&observed_daemon).unwrap(); - assert!(!rendered.contains("ffffffffffffffffffffffffffffffffffffffff")); + for declared in [ + build.build_git_sha.as_deref(), + build.build_lock_digest.as_deref(), + build.wire_crate_version.as_deref(), + build.store_schema_version.as_deref(), + ] + .into_iter() + .flatten() + { + assert!( + !rendered.contains(declared), + "daemon facts must not contain declared provenance value {declared:?}" + ); + } module.stop().await.unwrap(); } From 389f7ee15b79d80d5aaa274155228580440f562d Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:47:49 +0200 Subject: [PATCH 08/19] provenance: make the declared-field leakage sweep compile-exhaustive The sweep enumerated ManifestProvenance's fields by hand, so a field added later would escape it silently. Destructuring makes that a compile error (E0027) at the sweep instead. --- crates/subc-core/tests/provenance.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/subc-core/tests/provenance.rs b/crates/subc-core/tests/provenance.rs index bb598b07..35be0af3 100644 --- a/crates/subc-core/tests/provenance.rs +++ b/crates/subc-core/tests/provenance.rs @@ -18,6 +18,7 @@ use subc_core::{ read_frame, write_frame, Frame, ModuleSpec, RestartPolicy, Supervisor, SupervisorHandle, SupervisorProcessLiveness, }; +use subc_protocol::manifest::ManifestProvenance; use subc_protocol::{Flags, FrameType, Priority}; use tokio::{ io::AsyncWriteExt, @@ -101,11 +102,20 @@ async fn supervisor_provenance_reports_declared_and_observed_module_facts() { assert!(observed.daemon_observed.spawned_at_ms.unwrap_or_default() > 0); assert_running_image_matches(&observed.daemon_observed.running_image); let rendered = serde_json::to_string(&observed_daemon).unwrap(); + // Destructured rather than field-accessed so that adding a field to + // ManifestProvenance fails to compile here instead of silently escaping the + // leakage sweep below. + let ManifestProvenance { + build_git_sha, + build_lock_digest, + wire_crate_version, + store_schema_version, + } = build; for declared in [ - build.build_git_sha.as_deref(), - build.build_lock_digest.as_deref(), - build.wire_crate_version.as_deref(), - build.store_schema_version.as_deref(), + build_git_sha.as_deref(), + build_lock_digest.as_deref(), + wire_crate_version.as_deref(), + store_schema_version.as_deref(), ] .into_iter() .flatten() From b063e51d543c982be5939fd01c9f775c6c89ae1a Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:37:44 +0200 Subject: [PATCH 09/19] ck: add module provenance command --- crates/subc-core/src/bin/ck.rs | 147 ++++++++++++++++++++++- crates/subc-core/tests/ck_cli.rs | 199 +++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 2 deletions(-) diff --git a/crates/subc-core/src/bin/ck.rs b/crates/subc-core/src/bin/ck.rs index eccb218e..e8e33b43 100644 --- a/crates/subc-core/src/bin/ck.rs +++ b/crates/subc-core/src/bin/ck.rs @@ -48,7 +48,7 @@ const CK_HARNESS: &str = "ck"; // production baseline data; then calibrate whether every window minute is needed. const FRAME_DROP_ALERT_REQUIRED_NONZERO_MINUTES: u64 = 10; -const TOP_HELP_BASE: &str = "ck — CortexKit operator CLI\n\nusage:\n ck [--subc ] [--json] [] []\n\ndomains:\n module supervised modules: list, status, stderr, terminals, restart, stop, start, rescan, release\n routes live consumers for one module or the whole daemon\n health one-line health for every supervised module\n quota AI-provider quota and usage windows\n fleet offline configured-module inspection\n daemon daemon version, uptime, connection info, and offline triage"; +const TOP_HELP_BASE: &str = "ck — CortexKit operator CLI\n\nusage:\n ck [--subc ] [--json] [] []\n\ndomains:\n module supervised modules: list, status, stderr, terminals, restart, stop, start, rescan, release\n routes live consumers for one module or the whole daemon\n provenance daemon-attested and module-declared build/process facts\n health one-line health for every supervised module\n quota AI-provider quota and usage windows\n fleet offline configured-module inspection\n daemon daemon version, uptime, connection info, and offline triage"; const TOP_HELP_TAIL: &str = "flags:\n --subc use a specific connection file (default: auto-discover)\n --json raw JSON output instead of tables\n\nrun 'ck ' with no verb to see that domain's commands"; @@ -117,6 +117,8 @@ const MODULE_HELP: &str = "ck module — inspect and control supervised modules\ const ROUTES_HELP: &str = "ck routes — inspect live route consumers\n\nusage: ck [--json] routes []\n\n ck routes live consumers for every connected module\n ck routes live consumers for one connected module"; +const PROVENANCE_HELP: &str = "ck provenance — inspect source-tagged module provenance\n\nusage: ck [--json] provenance \n\n ck provenance daemon-attested process facts beside module declarations"; + const QUOTA_HELP: &str = "ck quota - AI-provider quota and usage windows\n\nusage: ck [--json] quota [--verbose] []\n\n ck quota connected providers and their usage windows\n ck quota --verbose all tracked providers, including unavailable ones\n ck quota claude one provider's windows and status in detail"; const HEALTH_HELP: &str = "ck health — module health\n\nusage: ck [--json] health []\n\n ck health one-line health for every supervised module (cached)\n ck health fresh health.check probe with FULL metrics — bypasses\n the supervisor cache and its size truncation"; @@ -207,6 +209,7 @@ async fn run(argv: impl IntoIterator) -> Result<(), CkError> { ) .await } + Command::Provenance { module_id } => provenance(&mut client, &module_id, args.json).await, Command::Health => health(&mut client, args.json, args.subc.as_deref()).await, Command::HealthDetail { module_id } => { health_detail(&mut client, &module_id, args.json).await @@ -561,6 +564,9 @@ enum Command { Routes { module_id: Option, }, + Provenance { + module_id: String, + }, Health, HealthDetail { module_id: String, @@ -1292,6 +1298,130 @@ async fn supervisor_routes( Ok(()) } +async fn provenance( + client: &mut CkClient, + module_id: &str, + json_output: bool, +) -> Result<(), CkError> { + let response = client + .rpc_value(ClientControlRequest::SupervisorProvenance { + module_id: Some(module_id.to_string()), + }) + .await?; + if json_output { + print_json(&response)?; + return Ok(()); + } + + let daemon = response + .get("daemon") + .ok_or_else(|| CkError::Message("provenance response omitted daemon".to_string()))?; + let module = modules_array(&response) + .first() + .ok_or_else(|| CkError::Message("provenance response omitted module".to_string()))?; + + println!("DAEMON BUILD"); + let daemon_build = daemon.get("daemon_build").unwrap_or(&Value::Null); + println!( + " COMMIT: {}", + provenance_value(daemon_build.get("build_git_sha")) + ); + println!( + " LOCK DIGEST: {}", + provenance_value(daemon_build.get("build_lock_digest")) + ); + println!("DAEMON-OBSERVED"); + let daemon_observed = daemon.get("daemon_observed").unwrap_or(&Value::Null); + println!(" PID: {}", provenance_value(daemon_observed.get("pid"))); + println!( + " START TIME: {}", + provenance_value(daemon_observed.get("started_at_ms")) + ); + println!( + " RUNNING IMAGE: {}", + provenance_image(daemon_observed.get("running_image")) + ); + + println!("MODULE: {}", display_field(module, "module_id")); + println!("MODULE-DECLARED"); + match module.get("module_declared") { + Some(declared) if declared.get("status").and_then(Value::as_str) == Some("reported") => { + let build = declared.get("build").unwrap_or(&Value::Null); + let commit = build.get("build_git_sha"); + println!(" COMMIT: {}", provenance_value(commit)); + if commit + .and_then(Value::as_str) + .is_some_and(|value| value.ends_with("-dirty")) + { + println!(" STATUS: commit match only"); + } + let lock = build.get("build_lock_digest"); + println!(" LOCK DIGEST: {}", provenance_value(lock)); + if commit.is_none() && lock.is_some() { + println!(" STATUS: change-detectable; commit identity unavailable"); + } + println!( + " WIRE CRATE VERSION: {}", + provenance_value(build.get("wire_crate_version")) + ); + println!( + " STORE SCHEMA VERSION: {}", + provenance_value(build.get("store_schema_version")) + ); + } + _ => println!(" unverifiable"), + } + + println!("DAEMON-OBSERVED"); + let observed = module.get("daemon_observed").unwrap_or(&Value::Null); + println!(" PID: {}", provenance_value(observed.get("pid"))); + println!( + " SPAWN TIME: {}", + provenance_value(observed.get("spawned_at_ms")) + ); + println!( + " SPAWNED-FROM: {}", + provenance_value(observed.get("spawned_from")) + ); + println!( + " RUNNING IMAGE: {}", + provenance_image(observed.get("running_image")) + ); + Ok(()) +} + +fn provenance_value(value: Option<&Value>) -> String { + match value { + Some(Value::String(value)) => value.clone(), + Some(Value::Number(value)) => value.to_string(), + Some(Value::Bool(value)) => value.to_string(), + _ => "unavailable".to_string(), + } +} + +fn provenance_image(value: Option<&Value>) -> String { + let Some(value) = value else { + return "unavailable".to_string(); + }; + let status = value + .get("status") + .and_then(Value::as_str) + .unwrap_or("unavailable"); + match status { + "match" => format!( + "match ({})", + provenance_value( + value + .get("evidence") + .and_then(|evidence| evidence.get("method")) + ) + ), + "mismatch" => "mismatch (running vs disk)".to_string(), + "unavailable" => format!("unavailable ({})", provenance_value(value.get("reason"))), + other => other.to_string(), + } +} + async fn print_ack_with_state( client: &mut CkClient, module_id: &str, @@ -3865,7 +3995,7 @@ fn parse_args(argv: impl IntoIterator) -> Result bool { matches!( domain, - "module" | "routes" | "health" | "daemon" | "quota" | "fleet" | "help" + "module" | "routes" | "provenance" | "health" | "daemon" | "quota" | "fleet" | "help" ) } @@ -3878,6 +4008,7 @@ fn parse_command(domain: &str, tail: &[OsString]) -> Result { Ok(Command::Help(match topic.as_deref() { Some("module") => MODULE_HELP.into(), Some("routes") => ROUTES_HELP.into(), + Some("provenance") => PROVENANCE_HELP.into(), Some("quota") => QUOTA_HELP.into(), Some("fleet") => FLEET_HELP.into(), Some("health") => HEALTH_HELP.into(), @@ -3960,6 +4091,18 @@ fn parse_command(domain: &str, tail: &[OsString]) -> Result { } _ => Ok(Command::Help(ROUTES_HELP.into())), }, + "provenance" => { + let Some(module_id) = tail.first() else { + return Ok(Command::Help(PROVENANCE_HELP.into())); + }; + if tail.len() != 1 || module_id == "-h" || module_id == "--help" || module_id == "help" + { + return Ok(Command::Help(PROVENANCE_HELP.into())); + } + Ok(Command::Provenance { + module_id: module_id.to_string_lossy().into_owned(), + }) + } "health" => match tail.first() { None => Ok(Command::Health), Some(argument) => { diff --git a/crates/subc-core/tests/ck_cli.rs b/crates/subc-core/tests/ck_cli.rs index 465b61fa..47938fe3 100644 --- a/crates/subc-core/tests/ck_cli.rs +++ b/crates/subc-core/tests/ck_cli.rs @@ -305,6 +305,167 @@ async fn routes_empty_result_has_a_next_step_and_json_has_no_footer() { ); } +#[test] +fn provenance_requires_exactly_one_module_id_without_connecting() { + for args in [ + vec!["provenance"], + vec!["provenance", "--help"], + vec!["provenance", "aft", "extra"], + ] { + let output = ck_command().args(args).output().unwrap(); + assert_exit(&output, 0); + let stdout = text(&output.stdout); + assert!(stdout.contains("ck provenance"), "stdout:\n{stdout}"); + assert!(stdout.contains("usage:"), "stdout:\n{stdout}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn provenance_json_preserves_the_complete_daemon_response_without_a_footer() { + let server = TestServer::start().await; + let supervisor = supervisor(&server); + let module = spawn_stub_with_env( + &server, + &supervisor, + "aft", + vec![ + ("FAKE_AFT_BUILD_COMMIT", "declared-commit"), + ("FAKE_AFT_BUILD_LOCK_DIGEST", "declared-lock"), + ("FAKE_AFT_WIRE_CRATE_VERSION", "declared-wire"), + ("FAKE_AFT_STORE_SCHEMA_VERSION", "declared-schema"), + ], + ) + .await; + + let expected = control_rpc_value_on_stream( + &mut wait_for_client(&server.connection_file_path).await, + 91, + ClientControlRequest::SupervisorProvenance { + module_id: Some("aft".to_string()), + }, + ) + .await; + let output = ck_with_subc( + &server.connection_file_path, + ["--json", "provenance", "aft"], + ); + assert_exit(&output, 0); + let stdout = text(&output.stdout); + let actual: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(actual, expected); + assert!(stdout.contains("\"module_declared\""), "stdout:\n{stdout}"); + assert!(stdout.contains("\"daemon_observed\""), "stdout:\n{stdout}"); + assert!(!stdout.contains("help["), "stdout:\n{stdout}"); + + module.stop().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn provenance_human_output_keeps_declared_values_under_the_declared_label() { + let server = TestServer::start().await; + let supervisor = supervisor(&server); + let module = spawn_stub_with_env( + &server, + &supervisor, + "aft", + vec![ + ("FAKE_AFT_BUILD_COMMIT", "declared-dirty-dirty"), + ("FAKE_AFT_BUILD_LOCK_DIGEST", "declared-lock"), + ("FAKE_AFT_WIRE_CRATE_VERSION", "declared-wire"), + ("FAKE_AFT_STORE_SCHEMA_VERSION", "declared-schema"), + ], + ) + .await; + + let output = ck_with_subc(&server.connection_file_path, ["provenance", "aft"]); + assert_exit(&output, 0); + let stdout = text(&output.stdout); + let declared_at = stdout + .find("MODULE-DECLARED") + .unwrap_or_else(|| panic!("stdout:\n{stdout}")); + let observed_at = stdout + .rfind("DAEMON-OBSERVED") + .unwrap_or_else(|| panic!("stdout:\n{stdout}")); + assert!(declared_at < observed_at, "stdout:\n{stdout}"); + for declared in [ + "declared-dirty-dirty", + "declared-lock", + "declared-wire", + "declared-schema", + ] { + let value_at = stdout + .find(declared) + .unwrap_or_else(|| panic!("missing {declared:?} in:\n{stdout}")); + assert!( + declared_at < value_at && value_at < observed_at, + "declared value {declared:?} escaped its source section:\n{stdout}" + ); + } + assert!(stdout.contains("DAEMON BUILD"), "stdout:\n{stdout}"); + assert_eq!( + stdout.matches("DAEMON BUILD").count(), + 1, + "stdout:\n{stdout}" + ); + assert!(stdout.contains("MODULE: aft"), "stdout:\n{stdout}"); + assert!(stdout.contains("PID:"), "stdout:\n{stdout}"); + assert!(stdout.contains("SPAWN TIME:"), "stdout:\n{stdout}"); + assert!(stdout.contains("SPAWNED-FROM:"), "stdout:\n{stdout}"); + assert!(stdout.contains("RUNNING IMAGE:"), "stdout:\n{stdout}"); + assert!(stdout.contains("RUNNING IMAGE: match"), "stdout:\n{stdout}"); + assert!(stdout.contains("linux_proc_sha256"), "stdout:\n{stdout}"); + assert!(stdout.contains("commit match only"), "stdout:\n{stdout}"); + + module.stop().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn provenance_renders_unverifiable_and_lock_only_declarations() { + let server = TestServer::start().await; + let supervisor = supervisor(&server); + let unverifiable = spawn_stub(&server, &supervisor, "unverifiable").await; + let lock_only = spawn_stub_with_env( + &server, + &supervisor, + "lock-only", + vec![("FAKE_AFT_BUILD_LOCK_DIGEST", "declared-lock-only")], + ) + .await; + + let absent = ck_with_subc(&server.connection_file_path, ["provenance", "unverifiable"]); + assert_exit(&absent, 0); + assert!( + text(&absent.stdout).contains("unverifiable"), + "stdout:\n{}", + text(&absent.stdout) + ); + + let lock_only_output = ck_with_subc(&server.connection_file_path, ["provenance", "lock-only"]); + assert_exit(&lock_only_output, 0); + assert!( + text(&lock_only_output.stdout).contains("change-detectable; commit identity unavailable"), + "stdout:\n{}", + text(&lock_only_output.stdout) + ); + + unverifiable.stop().await.unwrap(); + lock_only.stop().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn provenance_unknown_module_preserves_the_daemon_error_and_exit_code() { + let server = TestServer::start().await; + + let output = ck_with_subc( + &server.connection_file_path, + ["provenance", "missing-module"], + ); + assert_exit(&output, 1); + let stderr = text(&output.stderr); + assert!(stderr.contains("unknown_module"), "stderr:\n{stderr}"); + assert!(stderr.contains("missing-module"), "stderr:\n{stderr}"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unknown_module_error_has_a_next_step_and_json_has_no_footer() { let server = TestServer::start().await; @@ -617,6 +778,25 @@ async fn spawn_stub( module } +async fn spawn_stub_with_env( + server: &TestServer, + supervisor: &Supervisor, + module_id: &str, + env: Vec<(&str, &str)>, +) -> SupervisedModule { + let mut spec = stub_spec(module_id); + spec.env.extend( + env.into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())), + ); + let module = supervisor.spawn(spec).unwrap(); + wait_for_supervisor_entry(&server.connection_file_path, module_id, |entry| { + entry.state == "running" && entry.enabled && entry.live + }) + .await; + module +} + fn stub_spec(module_id: &str) -> ModuleSpec { ModuleSpec { module_id: module_id.to_string(), @@ -781,6 +961,25 @@ where } } +async fn control_rpc_value_on_stream( + stream: &mut S, + corr: u64, + request: ClientControlRequest, +) -> Value +where + S: AsyncRead + AsyncWrite + Unpin, +{ + write_frame(stream, &control_request_frame(corr, request)) + .await + .unwrap(); + stream.flush().await.unwrap(); + let frame = read_frame_timeout(stream).await; + assert_eq!(frame.header.channel, 0); + assert_eq!(frame.header.corr, corr); + assert_eq!(frame.header.ty, FrameType::Response); + serde_json::from_slice(&frame.body).unwrap() +} + fn control_request_frame(corr: u64, request: ClientControlRequest) -> Frame { let body = serde_json::to_vec(&request).unwrap(); Frame::build( From dfc7d7403d26c2f3802fe638b1ca6e71d52c7606 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:51:42 +0200 Subject: [PATCH 10/19] ck: assert declared values never render under the observed label --- crates/subc-core/tests/ck_cli.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/subc-core/tests/ck_cli.rs b/crates/subc-core/tests/ck_cli.rs index 47938fe3..93d44fb7 100644 --- a/crates/subc-core/tests/ck_cli.rs +++ b/crates/subc-core/tests/ck_cli.rs @@ -387,19 +387,29 @@ async fn provenance_human_output_keeps_declared_values_under_the_declared_label( .rfind("DAEMON-OBSERVED") .unwrap_or_else(|| panic!("stdout:\n{stdout}")); assert!(declared_at < observed_at, "stdout:\n{stdout}"); + let module_declared = &stdout[declared_at..observed_at]; + let module_observed = &stdout[observed_at..]; + assert!( + module_observed.starts_with("DAEMON-OBSERVED\n PID:"), + "module-level observed section boundary was not verified:\n{module_observed}" + ); for declared in [ "declared-dirty-dirty", "declared-lock", "declared-wire", "declared-schema", ] { - let value_at = stdout + let value_at = module_declared .find(declared) .unwrap_or_else(|| panic!("missing {declared:?} in:\n{stdout}")); assert!( - declared_at < value_at && value_at < observed_at, + value_at < module_declared.len(), "declared value {declared:?} escaped its source section:\n{stdout}" ); + assert!( + !module_observed.contains(declared), + "declared value {declared:?} leaked into module-level observed section:\n{module_observed}" + ); } assert!(stdout.contains("DAEMON BUILD"), "stdout:\n{stdout}"); assert_eq!( From ad55568c6d2ccf7933a68500f9bd7832a3ef85bc Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:57:11 +0200 Subject: [PATCH 11/19] docs: document split provenance attestation --- docs/fleet-surface.md | 25 +++++++++++ docs/subc-control-protocol.md | 82 +++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/docs/fleet-surface.md b/docs/fleet-surface.md index d856c56f..10fbfd32 100644 --- a/docs/fleet-surface.md +++ b/docs/fleet-surface.md @@ -33,11 +33,36 @@ The router. State-free by design; these ops are served by the daemon directly. - `supervisor.reload` / `supervisor.set_enabled` [m]: config reload; enable/disable (re-enable resets restart budget). - `supervisor.rescan` [m]: reconcile module set against config; `--dry-run` preview; refuses absent config. - `supervisor.health` [q] / `supervisor.health_probe` [m]: cached health snapshots / fresh one-shot probe. +- `supervisor.provenance` [q]: daemon build/process facts beside each module's separately declared build facts; accepts an optional exact module filter. - `supervisor.routes` [q]: live route census — who holds routes to what, attested principals, ages, drain reasons. - `supervisor.stderr_tail` [q]: bounded per-module stderr ring with restart boundaries. - `supervisor.terminals` [q]: bounded per-module exit history (codes, signals, dispositions); readable even when the module's supervision task is wedged. - Channel-0 pushes: `route.closing` / `route.closed` (drain lifecycle, terminal flag). +### Provenance reads + +`supervisor.provenance` has two source-separated layers. `daemon` contains the +daemon's embedded build identity (`build_git_sha`, `build_lock_digest`) and its own +pid, start time, and running-image evidence. Each module entry contains +`module_declared`, copied from the module's optional HELLO manifest block, and +`daemon_observed`, captured by the supervisor at spawn and read from the running +process. A missing manifest block is `unverifiable`; it is not an empty build claim. + +The module declaration may contain `build_git_sha`, `build_lock_digest`, +`wire_crate_version`, and `store_schema_version`. The daemon never promotes those +values into its observed layer. `supervisor.provenance {}` reads the whole box; +`supervisor.provenance { module_id }` reads one module. + +On Linux, running-image evidence is SHA-256 over open handles for `/proc//exe` +and the captured spawn path. On macOS it is a spawn-inode comparison only, weaker +than a hash by design. Unsupported platforms return a typed unavailable result, not +a placeholder digest. Linux digesting is a cold read on the first observation; a +bounded process-local cache holds 64 file identities and clears when full. + +`ck provenance ` preserves these source labels in human output. With `--json` +it emits the typed response unchanged. This surface does not implement `origin_delta`, +`buildable_at_head`, deploy, git, or network logic. + ## aft — agent tool substrate (21 tools) Indexed code perception + editing for agent harnesses. diff --git a/docs/subc-control-protocol.md b/docs/subc-control-protocol.md index c16d322e..ad1c8dd1 100644 --- a/docs/subc-control-protocol.md +++ b/docs/subc-control-protocol.md @@ -94,9 +94,14 @@ pub enum ClientControlRequest { #[serde(rename = "supervisor.restart")] SupervisorRestart { module_id: String }, #[serde(rename = "supervisor.reload")] SupervisorReload { module_id: String }, // drain-to-quiescence hot-swap #[serde(rename = "supervisor.rescan")] SupervisorRescan {}, // reconcile module keys/specs from disk + #[serde(rename = "supervisor.release_reserved")] SupervisorReleaseReserved { module_id: String }, #[serde(rename = "supervisor.set_enabled")] SupervisorSetEnabled { module_id: String, enabled: bool }, #[serde(rename = "supervisor.health_probe")]SupervisorHealthProbe { module_id: String }, #[serde(rename = "supervisor.health")] SupervisorHealth {}, + #[serde(rename = "supervisor.routes")] SupervisorRoutes { module_id: Option }, + #[serde(rename = "supervisor.provenance")] SupervisorProvenance { module_id: Option }, + #[serde(rename = "supervisor.stderr_tail")] SupervisorStderrTail { module_id: String, max_lines: Option, max_bytes: Option }, + #[serde(rename = "supervisor.terminals")] SupervisorTerminals { module_id: String }, // FUTURE (additive): config.* (raw-tier get/put/changed) } @@ -110,6 +115,12 @@ pub enum ClientControlResponse { #[serde(rename = "supervisor.list")] SupervisorList { generation: u64, modules: Vec }, #[serde(rename = "supervisor.ack")] SupervisorAck { module_id: String, applied: bool }, #[serde(rename = "supervisor.rescan")] SupervisorRescan { #[serde(flatten)] result: SupervisorRescanResult }, + #[serde(rename = "supervisor.health_probe")] SupervisorHealthProbe { module_id: String, status: HealthStatus, detail: Option, metrics: Option }, + #[serde(rename = "supervisor.health")] SupervisorHealth { generation: u64, modules: Vec }, + #[serde(rename = "supervisor.routes")] SupervisorRoutes { modules: Vec }, + #[serde(rename = "supervisor.provenance")] SupervisorProvenance { daemon: SupervisorDaemonProvenance, modules: Vec }, + #[serde(rename = "supervisor.stderr_tail")] SupervisorStderrTail { module_id: String, #[serde(flatten)] tail: StderrTail }, + #[serde(rename = "supervisor.terminals")] SupervisorTerminals { module_id: String, #[serde(flatten)] terminals: TerminalHistory }, } #[derive(Serialize, Deserialize)] @@ -144,6 +155,77 @@ pub struct ModuleHelloBody { pub protocol_ver: u8, pub control_ops: Option>, // None = legacy baseline ONLY (never "all"); Some([])=no optional; Some([..])=exactly those } + +`ModuleManifest.provenance` is optional. It is a module declaration, not a daemon +observation: + +```rust +pub struct ModuleManifest { + // ...required manifest fields... + pub capabilities: Option, + pub provenance: Option, +} + +pub struct ManifestProvenance { + pub build_git_sha: Option, + pub build_lock_digest: Option, + pub wire_crate_version: Option, + pub store_schema_version: Option, +} +``` + +Each field is independently optional. The current `subc-core` build script emits +`CK_BUILD_REV` and `CK_BUILD_LOCK_DIGEST`; its exact grammar is: + +```text +cargo:rustc-env=CK_BUILD_REV=<40-hex SHA, or unavailable; -dirty when the tree is dirty> +cargo:rustc-env=CK_BUILD_LOCK_DIGEST= +``` + +`wire_crate_version` and `store_schema_version` are supplied by the SDK/build +integration, not by `subc-core/build.rs`. + +Old daemons ignore this additive optional block while decoding HELLO, so an absent or +present block remains HELLO-compatible. Rust consumers that construct `ModuleManifest` +with a struct literal are different: they must add `provenance: None` (or a value), or +the path-dependency consumer can fail with a missing-field compile error after the +protocol bump. + +The client request is `supervisor.provenance { module_id: Option }`. The +response keeps sources separate: + +```rust +pub struct SupervisorModuleProvenance { + pub module_id: String, + pub module_declared: ModuleDeclaredProvenance, + pub daemon_observed: SupervisorObservedProcess, +} +``` + +`module_declared` is `reported { build: ManifestProvenance }` or `unverifiable` when +HELLO omitted the block. `daemon_observed` contains pid, spawn time, exact +spawned-from path, and the running-image agreement. The response's `daemon` member +contains the daemon's own build identity and observed process facts. A module claim +never becomes a daemon-attested fact. + +Running-image evidence has three platform paths: + +* Linux opens `/proc//exe` and the captured spawn path, then compares SHA-256 + digests. Open handles make path replacement visible as a mismatch. +* macOS compares the spawn-time device/inode with the current path's device/inode. + This is comparison-only and weaker than a hash. That is deliberate. +* Other platforms return typed `unavailable { reason: "unsupported_platform" }`, never + a placeholder digest. + +The first Linux observation is a cold read of both executable files. A process-local +cache retains up to 64 file identities (device, inode, size, and modification time); +when it reaches 64 entries it is cleared. + +`ck provenance ` prints source labels in human output. `ck --json provenance +` prints the typed response without merging or relabeling fields. This surface +deliberately excludes `origin_delta`, `buildable_at_head`, deploy, git, and network +logic. + #[derive(Serialize, Deserialize)] #[serde(tag = "op")] // NOT deny_unknown_fields pub enum ModuleControlPush { #[serde(rename = "route.status")] RouteStatus { route_channel: u16, status: String }, // opaque, cached verbatim From 5433b85ecd933d5e89ffd85111e2af3fee64d0cb Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:17:26 +0200 Subject: [PATCH 12/19] manifest: bound declared provenance values and neutralize them at render CONSUMER-IMPACT: Manifests carrying non-printable or over-long provenance values are now refused where they were previously accepted; this changes behavior for any module already sending such values. --- crates/subc-core/src/bin/ck.rs | 27 ++++++- crates/subc-core/src/control.rs | 16 +++- crates/subc-core/tests/ck_cli.rs | 30 +++++++- crates/subc-protocol/src/manifest.rs | 94 ++++++++++++++++++++++- crates/subc-protocol/tests/golden_json.rs | 24 ++++++ 5 files changed, 185 insertions(+), 6 deletions(-) diff --git a/crates/subc-core/src/bin/ck.rs b/crates/subc-core/src/bin/ck.rs index e8e33b43..a375fcfa 100644 --- a/crates/subc-core/src/bin/ck.rs +++ b/crates/subc-core/src/bin/ck.rs @@ -1342,7 +1342,7 @@ async fn provenance( provenance_image(daemon_observed.get("running_image")) ); - println!("MODULE: {}", display_field(module, "module_id")); + println!("MODULE: {}", provenance_value(module.get("module_id"))); println!("MODULE-DECLARED"); match module.get("module_declared") { Some(declared) if declared.get("status").and_then(Value::as_str) == Some("reported") => { @@ -1392,7 +1392,13 @@ async fn provenance( fn provenance_value(value: Option<&Value>) -> String { match value { - Some(Value::String(value)) => value.clone(), + Some(Value::String(value)) => value + .bytes() + .map(|byte| match byte { + 0x20..=0x7e => (byte as char).to_string(), + _ => format!(r"\x{byte:02x}"), + }) + .collect(), Some(Value::Number(value)) => value.to_string(), Some(Value::Bool(value)) => value.to_string(), _ => "unavailable".to_string(), @@ -4455,6 +4461,23 @@ mod tests { use super::*; use subc_control::{StderrCaptureState, StderrTail, StderrTailEntry}; + #[test] + fn provenance_value_escapes_terminal_controls() { + for value in [ + "\u{1b}]52;c;AAAA\u{07}", + "\u{1b}[2J", + "\u{07}wire", + "schema\u{0a}", + ] { + let value = Value::String(value.to_string()); + let escaped = provenance_value(Some(&value)); + assert!(!escaped.bytes().any(|byte| byte < 0x20)); + assert!( + escaped.contains(r"\x1b") || escaped.contains(r"\x07") || escaped.contains(r"\x0a") + ); + } + } + #[test] fn dashboard_alert_line_requires_drops_in_every_window_minute() { let modules = Vec::new(); diff --git a/crates/subc-core/src/control.rs b/crates/subc-core/src/control.rs index b722c12e..d95e4c0a 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -18,8 +18,8 @@ use subc_control::{ use subc_protocol::{ error_codes, manifest::{ - validate_hello_capability_grammar, CapabilityDeclarations, Concurrency, ModuleManifest, - ProviderRole, + validate_hello_capability_grammar, CapabilityDeclarations, Concurrency, ManifestProvenance, + ModuleManifest, ProviderRole, }, session::{ HealthReport, ModuleControlPush, ModuleControlRequest, ModuleControlRequestFromModule, @@ -990,6 +990,18 @@ impl ControlHandler { err.to_string(), )?]); } + if let Some(provenance) = hello_value + .get("manifest") + .and_then(|manifest| manifest.get("provenance")) + { + if let Err(err) = serde_json::from_value::(provenance.clone()) { + return Ok(vec![control_error_frame( + &frame, + "invalid_manifest", + format!("malformed manifest provenance: {err}"), + )?]); + } + } let hello = match serde_json::from_value::(hello_value) { Ok(hello) => hello, Err(err) => { diff --git a/crates/subc-core/tests/ck_cli.rs b/crates/subc-core/tests/ck_cli.rs index 93d44fb7..1bcea23e 100644 --- a/crates/subc-core/tests/ck_cli.rs +++ b/crates/subc-core/tests/ck_cli.rs @@ -429,6 +429,25 @@ async fn provenance_human_output_keeps_declared_values_under_the_declared_label( module.stop().await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn provenance_hostile_declared_values_are_refused_before_rendering() { + let server = TestServer::start().await; + let supervisor = supervisor(&server); + let _module = supervisor + .spawn(stub_spec_with_env( + "aft", + vec![ + ("FAKE_AFT_BUILD_COMMIT", "\u{1b}]52;c;AAAA\u{07}"), + ("FAKE_AFT_BUILD_LOCK_DIGEST", "\u{1b}[2J"), + ], + )) + .unwrap(); + wait_for_supervisor_entry(&server.connection_file_path, "aft", |entry| { + entry.state == "failed" && !entry.live + }) + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn provenance_renders_unverifiable_and_lock_only_declarations() { let server = TestServer::start().await; @@ -808,11 +827,20 @@ async fn spawn_stub_with_env( } fn stub_spec(module_id: &str) -> ModuleSpec { + stub_spec_with_env(module_id, Vec::new()) +} + +fn stub_spec_with_env(module_id: &str, env: Vec<(&str, &str)>) -> ModuleSpec { ModuleSpec { module_id: module_id.to_string(), program: PathBuf::from(env!("CARGO_BIN_EXE_fake-aft-stub")), args: Vec::new(), - env: vec![("FAKE_AFT_MODULE_ID".to_string(), module_id.to_string())], + env: std::iter::once(("FAKE_AFT_MODULE_ID".to_string(), module_id.to_string())) + .chain( + env.into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())), + ) + .collect(), reserved: false, reserved_prefixes: Vec::new(), } diff --git a/crates/subc-protocol/src/manifest.rs b/crates/subc-protocol/src/manifest.rs index c35cf995..bcdaa0ed 100644 --- a/crates/subc-protocol/src/manifest.rs +++ b/crates/subc-protocol/src/manifest.rs @@ -92,7 +92,7 @@ pub struct CapabilityDeclarations { pub must_never_reach: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq)] pub struct ManifestProvenance { #[serde(default, skip_serializing_if = "Option::is_none")] pub build_git_sha: Option, @@ -104,6 +104,98 @@ pub struct ManifestProvenance { pub store_schema_version: Option, } +const MAX_PROVENANCE_VALUE_BYTES: usize = 128; + +#[derive(Deserialize)] +struct ManifestProvenanceWire { + #[serde(default)] + build_git_sha: Option, + #[serde(default)] + build_lock_digest: Option, + #[serde(default)] + wire_crate_version: Option, + #[serde(default)] + store_schema_version: Option, +} + +impl<'de> Deserialize<'de> for ManifestProvenance { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = ManifestProvenanceWire::deserialize(deserializer)?; + let provenance = Self { + build_git_sha: wire.build_git_sha, + build_lock_digest: wire.build_lock_digest, + wire_crate_version: wire.wire_crate_version, + store_schema_version: wire.store_schema_version, + }; + provenance.validate().map_err(D::Error::custom)?; + Ok(provenance) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestProvenanceError { + field: String, + value: String, + reason: &'static str, +} + +impl ManifestProvenanceError { + fn new(field: &str, value: &str, reason: &'static str) -> Self { + Self { + field: field.to_string(), + value: safe_error_value(value), + reason, + } + } + + pub fn field(&self) -> &str { + &self.field + } +} + +impl fmt::Display for ManifestProvenanceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "invalid manifest provenance: field {} has {} (value {:?})", + self.field, self.reason, self.value + ) + } +} + +impl std::error::Error for ManifestProvenanceError {} + +impl ManifestProvenance { + pub fn validate(&self) -> Result<(), ManifestProvenanceError> { + for (field, value) in [ + ("build_git_sha", self.build_git_sha.as_deref()), + ("build_lock_digest", self.build_lock_digest.as_deref()), + ("wire_crate_version", self.wire_crate_version.as_deref()), + ("store_schema_version", self.store_schema_version.as_deref()), + ] { + let Some(value) = value else { continue }; + if value.len() > MAX_PROVENANCE_VALUE_BYTES { + return Err(ManifestProvenanceError::new( + field, + value, + "exceeds the 128-byte maximum", + )); + } + if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) { + return Err(ManifestProvenanceError::new( + field, + value, + "contains non-printable ASCII", + )); + } + } + Ok(()) + } +} + /// One capability a module consumes and whether its absence is tolerated. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(deny_unknown_fields)] diff --git a/crates/subc-protocol/tests/golden_json.rs b/crates/subc-protocol/tests/golden_json.rs index 7e14c7e3..cef8041b 100644 --- a/crates/subc-protocol/tests/golden_json.rs +++ b/crates/subc-protocol/tests/golden_json.rs @@ -207,6 +207,30 @@ fn manifest_provenance_omits_each_unavailable_fact_independently() { } } +#[test] +fn manifest_provenance_rejects_non_printable_and_overlong_values() { + for field in [ + "build_git_sha", + "build_lock_digest", + "wire_crate_version", + "store_schema_version", + ] { + let mut encoded = + serde_json::to_value(module_hello_body_with_provenance()).expect("HELLO serializes"); + encoded["manifest"]["provenance"][field] = Value::String("\u{1b}[2J".to_string()); + let error = serde_json::from_value::(encoded) + .expect_err("non-printable provenance must refuse the manifest"); + assert!(error.to_string().contains(field), "error: {error}"); + + let mut encoded = + serde_json::to_value(module_hello_body_with_provenance()).expect("HELLO serializes"); + encoded["manifest"]["provenance"][field] = Value::String("x".repeat(129)); + let error = serde_json::from_value::(encoded) + .expect_err("overlong provenance must refuse the manifest"); + assert!(error.to_string().contains(field), "error: {error}"); + } +} + #[test] fn legacy_manifest_decoder_ignores_the_additive_provenance_block() { let encoded = serde_json::to_value(module_hello_body_with_provenance().manifest) From a5642daa0cfd2957478a781db4f145e41a8c666e Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:29:11 +0200 Subject: [PATCH 13/19] provenance: cover daemon self-identity and reject empty declarations CONSUMER-IMPACT: Manifest provenance values that are empty are now rejected during HELLO decoding; modules previously sending empty declarations must omit the field or provide a non-empty value. --- crates/subc-core/src/bootstrap.rs | 11 +++++++ crates/subc-core/src/supervise.rs | 1 - crates/subc-core/tests/closure.rs | 10 +++++- crates/subc-core/tests/common/mod.rs | 16 ++++++++-- crates/subc-core/tests/provenance.rs | 37 ++++++++++++++++++++++- crates/subc-protocol/src/manifest.rs | 7 +++++ crates/subc-protocol/tests/golden_json.rs | 21 +++++++++++++ 7 files changed, 98 insertions(+), 5 deletions(-) diff --git a/crates/subc-core/src/bootstrap.rs b/crates/subc-core/src/bootstrap.rs index 783a5264..741016d3 100644 --- a/crates/subc-core/src/bootstrap.rs +++ b/crates/subc-core/src/bootstrap.rs @@ -1031,6 +1031,17 @@ mod tests { static ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] + fn normalized_build_provenance_preserves_real_values() { + assert_eq!(normalized_build_provenance("abc"), Some("abc".to_string())); + } + + #[test] + fn normalized_build_provenance_omits_unavailable_and_empty_values() { + assert_eq!(normalized_build_provenance("unavailable"), None); + assert_eq!(normalized_build_provenance(""), None); + } + struct EnvGuard { key: &'static str, previous: Option, diff --git a/crates/subc-core/src/supervise.rs b/crates/subc-core/src/supervise.rs index a5bba216..5205492a 100644 --- a/crates/subc-core/src/supervise.rs +++ b/crates/subc-core/src/supervise.rs @@ -824,7 +824,6 @@ pub struct Supervisor { supervisor_handle: Option, health: HealthConfig, daemon_started_at_ms: u64, - #[allow(dead_code)] provenance_probe: ExecutableIdentityProbe, } diff --git a/crates/subc-core/tests/closure.rs b/crates/subc-core/tests/closure.rs index bfb2ca95..9f835b02 100644 --- a/crates/subc-core/tests/closure.rs +++ b/crates/subc-core/tests/closure.rs @@ -408,9 +408,17 @@ async fn assert_supervisor_provenance_reads_empty_supervisor( match response { ClientControlResponse::SupervisorProvenance { daemon, modules } => { assert!(modules.is_empty()); + #[cfg(any(target_os = "linux", target_os = "macos"))] assert!(matches!( daemon.daemon_observed.running_image, - subc_control::RunningImageAgreement::Unavailable { .. } + subc_control::RunningImageAgreement::Match { .. } + )); + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + assert!(matches!( + daemon.daemon_observed.running_image, + subc_control::RunningImageAgreement::Unavailable { + reason: subc_control::RunningImageUnavailableReason::UnsupportedPlatform + } )); } other => panic!("unexpected supervisor.provenance response: {other:?}"), diff --git a/crates/subc-core/tests/common/mod.rs b/crates/subc-core/tests/common/mod.rs index e39853c0..503a6ad7 100644 --- a/crates/subc-core/tests/common/mod.rs +++ b/crates/subc-core/tests/common/mod.rs @@ -9,7 +9,7 @@ use std::{ atomic::{AtomicU64, Ordering}, Arc, }, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use subc_core::{ @@ -146,7 +146,19 @@ async fn start_test_daemon_inner( let connected_clients = ConnectedClients::new(); let mut handler = ControlHandler::new(Arc::clone(®istry)) .with_connected_clients(connected_clients.clone()) - .with_route_bind_relay_timeouts(per_module_bind_timeouts); + .with_route_bind_relay_timeouts(per_module_bind_timeouts) + .with_daemon_provenance( + conn.pid, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX), + std::env::current_exe().ok(), + Some("test-daemon-build".to_string()), + Some("test-daemon-lock".to_string()), + ); if let Some(process_liveness) = process_liveness { handler = handler.with_process_liveness(process_liveness); } diff --git a/crates/subc-core/tests/provenance.rs b/crates/subc-core/tests/provenance.rs index 35be0af3..d1dc9b55 100644 --- a/crates/subc-core/tests/provenance.rs +++ b/crates/subc-core/tests/provenance.rs @@ -1,3 +1,4 @@ +use std::time::{SystemTime, UNIX_EPOCH}; use std::{ fs, path::PathBuf, @@ -30,11 +31,12 @@ use common::{ connect_authed_client, start_test_daemon_with_process_liveness_and_supervisor, TestDaemon, }; -const READ_TIMEOUT: Duration = Duration::from_secs(2); +const READ_TIMEOUT: Duration = Duration::from_secs(10); static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn supervisor_provenance_reports_declared_and_observed_module_facts() { + let before_start_ms = unix_ms(); let process_liveness = Arc::new(SupervisorProcessLiveness::new()); let supervisor_handle = SupervisorHandle::new(); let daemon = start_test_daemon_with_process_liveness_and_supervisor( @@ -43,6 +45,7 @@ async fn supervisor_provenance_reports_declared_and_observed_module_facts() { supervisor_handle.clone(), ) .await; + let after_start_ms = unix_ms(); let supervisor = Supervisor::new(Arc::clone(&daemon.registry), RestartPolicy::default()) .with_process_liveness(process_liveness) .with_handle(supervisor_handle) @@ -75,6 +78,29 @@ async fn supervisor_provenance_reports_declared_and_observed_module_facts() { else { panic!("supervisor.provenance must return a provenance response"); }; + assert_eq!( + observed_daemon.daemon_observed.pid, + Some(std::process::id()) + ); + assert!(matches!( + observed_daemon.daemon_observed.running_image, + RunningImageAgreement::Match { .. } + | RunningImageAgreement::Unavailable { + reason: subc_control::RunningImageUnavailableReason::UnsupportedPlatform + } + )); + assert!( + observed_daemon.daemon_observed.started_at_ms >= Some(before_start_ms) + && observed_daemon.daemon_observed.started_at_ms <= Some(after_start_ms) + ); + assert_eq!( + observed_daemon.daemon_build.build_git_sha.as_deref(), + Some("test-daemon-build") + ); + assert_eq!( + observed_daemon.daemon_build.build_lock_digest.as_deref(), + Some("test-daemon-lock") + ); assert_eq!(modules.len(), 1); let observed = &modules[0]; let ModuleDeclaredProvenance::Reported { build } = &observed.module_declared else { @@ -302,3 +328,12 @@ fn unique_temp_dir(label: &str) -> PathBuf { TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) )) } + +fn unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() + .try_into() + .unwrap() +} diff --git a/crates/subc-protocol/src/manifest.rs b/crates/subc-protocol/src/manifest.rs index bcdaa0ed..d790285d 100644 --- a/crates/subc-protocol/src/manifest.rs +++ b/crates/subc-protocol/src/manifest.rs @@ -177,6 +177,13 @@ impl ManifestProvenance { ("store_schema_version", self.store_schema_version.as_deref()), ] { let Some(value) = value else { continue }; + if value.is_empty() { + return Err(ManifestProvenanceError::new( + field, + value, + "must not be empty", + )); + } if value.len() > MAX_PROVENANCE_VALUE_BYTES { return Err(ManifestProvenanceError::new( field, diff --git a/crates/subc-protocol/tests/golden_json.rs b/crates/subc-protocol/tests/golden_json.rs index cef8041b..56f23a48 100644 --- a/crates/subc-protocol/tests/golden_json.rs +++ b/crates/subc-protocol/tests/golden_json.rs @@ -231,6 +231,27 @@ fn manifest_provenance_rejects_non_printable_and_overlong_values() { } } +#[test] +fn manifest_provenance_rejects_empty_values_for_every_field() { + for field in [ + "build_git_sha", + "build_lock_digest", + "wire_crate_version", + "store_schema_version", + ] { + let mut encoded = + serde_json::to_value(module_hello_body_with_provenance()).expect("HELLO serializes"); + encoded["manifest"]["provenance"][field] = Value::String(String::new()); + let error = serde_json::from_value::(encoded) + .expect_err("empty provenance must refuse the manifest"); + assert!(error.to_string().contains(field), "error: {error}"); + assert!( + error.to_string().contains("must not be empty"), + "error: {error}" + ); + } +} + #[test] fn legacy_manifest_decoder_ignores_the_additive_provenance_block() { let encoded = serde_json::to_value(module_hello_body_with_provenance().manifest) From 0c95954864d6d22bf6b8b3d5e73b87de7e551c96 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:49:25 +0200 Subject: [PATCH 14/19] docs: state the declared-provenance refusal contract --- docs/fleet-surface.md | 13 ++++++++++--- docs/subc-control-protocol.md | 17 +++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/fleet-surface.md b/docs/fleet-surface.md index 10fbfd32..b231b8d2 100644 --- a/docs/fleet-surface.md +++ b/docs/fleet-surface.md @@ -50,7 +50,13 @@ process. A missing manifest block is `unverifiable`; it is not an empty build cl The module declaration may contain `build_git_sha`, `build_lock_digest`, `wire_crate_version`, and `store_schema_version`. The daemon never promotes those -values into its observed layer. `supervisor.provenance {}` reads the whole box; +values into its observed layer. Each present value must be non-empty, at most 128 +bytes, and printable ASCII (`0x20`–`0x7e`). A violation refuses HELLO with +`invalid_manifest`, so the module does not register. An absent block is valid and +continues registration normally; it produces `module_declared.status = unverifiable`. +The bound exists because declared values are module-controlled and reach operator +terminals, so the daemon limits their size and character set at its boundary. +`supervisor.provenance {}` reads the whole box; `supervisor.provenance { module_id }` reads one module. On Linux, running-image evidence is SHA-256 over open handles for `/proc//exe` @@ -60,8 +66,9 @@ a placeholder digest. Linux digesting is a cold read on the first observation; a bounded process-local cache holds 64 file identities and clears when full. `ck provenance ` preserves these source labels in human output. With `--json` -it emits the typed response unchanged. This surface does not implement `origin_delta`, -`buildable_at_head`, deploy, git, or network logic. +it emits the typed response unchanged. Non-printable bytes in malformed declared +values are escaped in diagnostics rather than emitted raw. This surface does not +implement `origin_delta`, `buildable_at_head`, deploy, git, or network logic. ## aft — agent tool substrate (21 tools) diff --git a/docs/subc-control-protocol.md b/docs/subc-control-protocol.md index ad1c8dd1..a64bb833 100644 --- a/docs/subc-control-protocol.md +++ b/docs/subc-control-protocol.md @@ -174,7 +174,15 @@ pub struct ManifestProvenance { } ``` -Each field is independently optional. The current `subc-core` build script emits +Each field is independently optional. When present, each value must be non-empty, +at most 128 bytes, and printable ASCII (`0x20`–`0x7e`). A manifest that violates any +of these rules is refused at HELLO with `invalid_manifest`; the module does not +register. An absent `provenance` block is different: it is valid, registration +proceeds normally, and the response reports `module_declared.status = unverifiable`. +The bound exists because declared values are module-controlled and reach operator +terminals, so the daemon limits their size and character set at its boundary. + +The current `subc-core` build script emits `CK_BUILD_REV` and `CK_BUILD_LOCK_DIGEST`; its exact grammar is: ```text @@ -222,9 +230,10 @@ cache retains up to 64 file identities (device, inode, size, and modification ti when it reaches 64 entries it is cleared. `ck provenance ` prints source labels in human output. `ck --json provenance -` prints the typed response without merging or relabeling fields. This surface -deliberately excludes `origin_delta`, `buildable_at_head`, deploy, git, and network -logic. +` prints the typed response without merging or relabeling fields. Non-printable +bytes in a malformed declared value are escaped in diagnostics rather than emitted +raw. This surface deliberately excludes `origin_delta`, `buildable_at_head`, deploy, +git, and network logic. #[derive(Serialize, Deserialize)] #[serde(tag = "op")] // NOT deny_unknown_fields pub enum ModuleControlPush { From da2db99d2bce750df05a5c2a39e214969dcb8b88 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:44:56 +0200 Subject: [PATCH 15/19] provenance: revert to SUBC_BUILD_GIT_SHA / SUBC_BUILD_LOCK_DIGEST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CK_BUILD_REV rename replaced names that already ship on master (build.rs emission, server.describe build fields, the ck skew check) — subconscious's own provenance skew detector. Reverting per the #58 ruling keeps the rename from silently retargeting it. The two names now mark two different provenance sources rather than one name meaning either: SUBC_BUILD_* is this repo's build.rs git probe, while CK_BUILD_REV is injected as a process env by release scripts in other fleet repos. Blast radius is four in-crate sites; cargo:rustc-env never escapes the crate build, so no consumer sees the change. CONSUMER-IMPACT: none — reverts to the names already shipped on master. --- crates/subc-core/build.rs | 4 ++-- crates/subc-core/src/bin/ck.rs | 2 +- crates/subc-core/src/bootstrap.rs | 4 ++-- crates/subc-core/src/control.rs | 4 ++-- docs/subc-control-protocol.md | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/subc-core/build.rs b/crates/subc-core/build.rs index 7310b04f..c1da46d2 100644 --- a/crates/subc-core/build.rs +++ b/crates/subc-core/build.rs @@ -24,11 +24,11 @@ fn main() { // would compare EQUAL between two broken builds, which reads as a verified // match precisely when nothing was verified. println!( - "cargo:rustc-env=CK_BUILD_REV={}", + "cargo:rustc-env=SUBC_BUILD_GIT_SHA={}", git_head_sha().unwrap_or_else(|| "unavailable".to_string()) ); println!( - "cargo:rustc-env=CK_BUILD_LOCK_DIGEST={}", + "cargo:rustc-env=SUBC_BUILD_LOCK_DIGEST={}", lock_digest().unwrap_or_else(|| "unavailable".to_string()) ); } diff --git a/crates/subc-core/src/bin/ck.rs b/crates/subc-core/src/bin/ck.rs index a375fcfa..916cc844 100644 --- a/crates/subc-core/src/bin/ck.rs +++ b/crates/subc-core/src/bin/ck.rs @@ -2156,7 +2156,7 @@ async fn daemon(client: &mut CkClient, json_output: bool) -> Result<(), CkError> /// nothing, and that prints as unverifiable rather than as a match -- absence /// of the check must not read as the check passing. fn print_build_skew(describe: &Value) { - let cli_sha = env!("CK_BUILD_REV"); + let cli_sha = env!("SUBC_BUILD_GIT_SHA"); let daemon_sha = describe.get("build_git_sha").and_then(Value::as_str); match daemon_sha { None => println!( diff --git a/crates/subc-core/src/bootstrap.rs b/crates/subc-core/src/bootstrap.rs index 741016d3..af5efc32 100644 --- a/crates/subc-core/src/bootstrap.rs +++ b/crates/subc-core/src/bootstrap.rs @@ -453,8 +453,8 @@ async fn serve_bound_daemon( bound.connection_info.pid, control_started_at_ms, std::env::current_exe().ok(), - normalized_build_provenance(env!("CK_BUILD_REV")), - normalized_build_provenance(env!("CK_BUILD_LOCK_DIGEST")), + normalized_build_provenance(env!("SUBC_BUILD_GIT_SHA")), + normalized_build_provenance(env!("SUBC_BUILD_LOCK_DIGEST")), ) .with_capability_config( configured_modules diff --git a/crates/subc-core/src/control.rs b/crates/subc-core/src/control.rs index d95e4c0a..37d06b4e 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -1435,8 +1435,8 @@ impl ControlHandler { capabilities: self.subc_capabilities.as_ref().to_vec(), connected_clients: self.connected_clients.count(), counters: Some(counters), - build_git_sha: Some(env!("CK_BUILD_REV").to_string()), - build_lock_digest: Some(env!("CK_BUILD_LOCK_DIGEST").to_string()), + build_git_sha: Some(env!("SUBC_BUILD_GIT_SHA").to_string()), + build_lock_digest: Some(env!("SUBC_BUILD_LOCK_DIGEST").to_string()), capability_requirements: self.capability_requirement_statuses(), }; Ok(vec![control_response_body_frame( diff --git a/docs/subc-control-protocol.md b/docs/subc-control-protocol.md index a64bb833..8f37426a 100644 --- a/docs/subc-control-protocol.md +++ b/docs/subc-control-protocol.md @@ -183,11 +183,11 @@ The bound exists because declared values are module-controlled and reach operato terminals, so the daemon limits their size and character set at its boundary. The current `subc-core` build script emits -`CK_BUILD_REV` and `CK_BUILD_LOCK_DIGEST`; its exact grammar is: +`SUBC_BUILD_GIT_SHA` and `SUBC_BUILD_LOCK_DIGEST`; its exact grammar is: ```text -cargo:rustc-env=CK_BUILD_REV=<40-hex SHA, or unavailable; -dirty when the tree is dirty> -cargo:rustc-env=CK_BUILD_LOCK_DIGEST= +cargo:rustc-env=SUBC_BUILD_GIT_SHA=<40-hex SHA, or unavailable; -dirty when the tree is dirty> +cargo:rustc-env=SUBC_BUILD_LOCK_DIGEST= ``` `wire_crate_version` and `store_schema_version` are supplied by the SDK/build From 0d238981387827d92d35534ae68707c4b0070898 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:04:55 +0200 Subject: [PATCH 16/19] test: assert the running-image verdict, not one platform's evidence spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provenance human-output test pinned linux_proc_sha256, so it failed honestly on macOS (which reports macos_spawn_inode) and on Windows (which has no probe and correctly reports unavailable). The cfg split now lands on capability rather than spelling: probe-capable platforms assert a match plus membership in the closed RunningImageEvidence set, and platforms without a probe assert the unavailable verdict positively rather than skipping. CONSUMER-IMPACT: none — test-only. --- crates/subc-core/tests/ck_cli.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/subc-core/tests/ck_cli.rs b/crates/subc-core/tests/ck_cli.rs index 1bcea23e..e7a4c7fe 100644 --- a/crates/subc-core/tests/ck_cli.rs +++ b/crates/subc-core/tests/ck_cli.rs @@ -422,8 +422,27 @@ async fn provenance_human_output_keeps_declared_values_under_the_declared_label( assert!(stdout.contains("SPAWN TIME:"), "stdout:\n{stdout}"); assert!(stdout.contains("SPAWNED-FROM:"), "stdout:\n{stdout}"); assert!(stdout.contains("RUNNING IMAGE:"), "stdout:\n{stdout}"); - assert!(stdout.contains("RUNNING IMAGE: match"), "stdout:\n{stdout}"); - assert!(stdout.contains("linux_proc_sha256"), "stdout:\n{stdout}"); + let running_image = stdout + .lines() + .find_map(|line| line.strip_prefix(" RUNNING IMAGE: ")) + .unwrap_or_else(|| panic!("missing running-image line in:\n{stdout}")); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + assert!(running_image.starts_with("match ("), "stdout:\n{stdout}"); + let method = running_image + .strip_prefix("match (") + .and_then(|value| value.strip_suffix(')')) + .unwrap_or_else(|| panic!("malformed running-image line:\n{stdout}")); + assert!( + matches!(method, "linux_proc_sha256" | "macos_spawn_inode"), + "unexpected running-image evidence method {method:?}:\n{stdout}" + ); + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + assert_eq!( + running_image, "unavailable (unsupported_platform)", + "stdout:\n{stdout}" + ); assert!(stdout.contains("commit match only"), "stdout:\n{stdout}"); module.stop().await.unwrap(); From 7cfeb6ae87fedd46482a6c1c8b2bec14b0b82a1d Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:29:19 +0200 Subject: [PATCH 17/19] fix: gate the evidence import to the platforms that construct it Windows CI rejected `subc-core` (lib) under -D warnings: both RunningImageEvidence constructors are cfg-gated to their probing platform (LinuxProcSha256 under target_os="linux", MacosSpawnInode under any(target_os="macos", test)), so on a platform with no probe the unqualified import had no user. The gate is the union of its users, including `test`, and that term is load-bearing rather than defensive: compare_spawn_inode is compiled under cfg(test) on every platform, so a Windows *test* build does construct the type. Gating on any(linux, macos) alone would have traded the lib break for a test break on the same platform. Verified by mutation: narrowing the gate to target_os="macos" fails the Linux build at both constructor sites (E0433, 2 errors). Windows itself remains unverifiable locally -- no rustup, so no cross-target check -- and is left to the twin. CONSUMER-IMPACT: none -- compile-gating only, no behavior or wire change. --- crates/subc-core/src/provenance.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/subc-core/src/provenance.rs b/crates/subc-core/src/provenance.rs index 4d9d76c7..2254310b 100644 --- a/crates/subc-core/src/provenance.rs +++ b/crates/subc-core/src/provenance.rs @@ -11,7 +11,11 @@ use std::{ #[cfg(target_os = "linux")] use sha2::{Digest, Sha256}; -use subc_control::{RunningImageAgreement, RunningImageEvidence, RunningImageUnavailableReason}; +use subc_control::{RunningImageAgreement, RunningImageUnavailableReason}; +// Both evidence constructors are cfg-gated to their probing platform, so on a +// platform without a probe this import has no user and -D warnings rejects it. +#[cfg(any(target_os = "linux", target_os = "macos", test))] +use subc_control::RunningImageEvidence; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct SpawnedFileIdentity { From 414cfad8010612d29d2a45d875cd9cf5b8b6701d Mon Sep 17 00:00:00 2001 From: ualtinok Date: Wed, 26 Aug 2026 15:34:23 +0300 Subject: [PATCH 18/19] twin: add SupervisorProvenance arm to dispatch-op discriminant (semantic conflict with timing instrumentation) --- crates/subc-core/src/control.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/subc-core/src/control.rs b/crates/subc-core/src/control.rs index 37d06b4e..8099891b 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -3470,6 +3470,7 @@ fn log_slow_control_dispatch( fn client_control_request_op(request: &ClientControlRequest) -> &'static str { match request { ClientControlRequest::ServerDescribe {} => ops::SERVER_DESCRIBE, + ClientControlRequest::SupervisorProvenance { .. } => ops::SUPERVISOR_PROVENANCE, ClientControlRequest::CatalogList { .. } => ops::CATALOG_LIST, ClientControlRequest::RouteOpen { .. } => ops::ROUTE_OPEN, ClientControlRequest::RoutePoll { .. } => ops::ROUTE_POLL, From 3e3d984f69cd2db2ac2014cbbcee24ef9bac328c Mon Sep 17 00:00:00 2001 From: ualtinok Date: Wed, 26 Aug 2026 17:13:17 +0300 Subject: [PATCH 19/19] twin: platform-gate provenance test imports (macOS + Windows clippy under -D warnings) --- crates/subc-core/src/provenance.rs | 11 +++++++++-- crates/subc-core/tests/provenance.rs | 20 +++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/subc-core/src/provenance.rs b/crates/subc-core/src/provenance.rs index 2254310b..9643a4ab 100644 --- a/crates/subc-core/src/provenance.rs +++ b/crates/subc-core/src/provenance.rs @@ -230,13 +230,20 @@ impl ImageDigestCache { #[cfg(test)] mod tests { use std::{ - fs::{self, File}, + fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; + // Only the linux sha256 tests open files directly, and only non-linux + // platforms assert the unavailable arm; each import gates with its users + // so the other platforms' clippy does not fail them as unused. + #[cfg(target_os = "linux")] + use std::fs::File; use super::*; - use subc_control::{RunningImageAgreement, RunningImageUnavailableReason}; + use subc_control::RunningImageAgreement; + #[cfg(target_os = "linux")] + use subc_control::RunningImageUnavailableReason; fn temp_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/crates/subc-core/tests/provenance.rs b/crates/subc-core/tests/provenance.rs index d1dc9b55..d600a5b9 100644 --- a/crates/subc-core/tests/provenance.rs +++ b/crates/subc-core/tests/provenance.rs @@ -1,19 +1,23 @@ +use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; +use std::{sync::Arc, time::Duration}; +// The sha256 re-hash apparatus (throwaway binaries in per-test temp dirs) is +// LINUX-only — macOS proves identity by spawn inode and Windows serves the +// unavailable arm, neither writes files — so these imports gate with it or +// the other platforms clippy-fail them as unused under -D warnings. +#[cfg(target_os = "linux")] use std::{ fs, - path::PathBuf, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, - }, - time::Duration, + sync::atomic::{AtomicU64, Ordering}, }; +// Used by the linux AND macos match arms of assert_running_image_matches. +#[cfg(not(target_os = "windows"))] +use subc_control::RunningImageEvidence; #[cfg(target_os = "windows")] use subc_control::RunningImageUnavailableReason; use subc_control::{ ClientControlRequest, ClientControlResponse, ModuleDeclaredProvenance, RunningImageAgreement, - RunningImageEvidence, }; use subc_core::{ read_frame, write_frame, Frame, ModuleSpec, RestartPolicy, Supervisor, SupervisorHandle, @@ -32,6 +36,7 @@ use common::{ }; const READ_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(target_os = "linux")] static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -321,6 +326,7 @@ fn assert_running_image_matches(result: &RunningImageAgreement) { )); } +#[cfg(target_os = "linux")] fn unique_temp_dir(label: &str) -> PathBuf { std::env::temp_dir().join(format!( "subc-{label}-{}-{}",