diff --git a/Cargo.lock b/Cargo.lock index da3b2bdd..1c8dec64 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", @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "subc-control" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde", "serde_json", @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "subc-core" -version = "0.7.0" +version = "0.8.0" dependencies = [ "cortexkit-paths", "fs4", @@ -905,6 +905,7 @@ dependencies = [ "rlimit", "serde", "serde_json", + "sha2", "subc-control", "subc-jsonc", "subc-protocol", 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..a8dcc10a 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." @@ -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-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..665a7c4c 100644 --- a/crates/subc-client-rs/src/lib.rs +++ b/crates/subc-client-rs/src/lib.rs @@ -44,12 +44,31 @@ 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_git_sha: Option<&str>, + build_lock_digest: Option<&str>, + store_schema_version: Option<&str>, +) -> ManifestProvenance { + ManifestProvenance { + 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), + } +} + +fn normalize_provenance_fact(value: Option<&str>) -> Option { + let value = value?.trim(); + (!value.is_empty() && value != "unavailable").then(|| value.to_string()) +} + use subc_transport::{ authenticate_client, connection_file, read_frame, write_frame, AuthError, ConnectionFileError, FrameIoError, @@ -1520,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-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-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..b065fef6 --- /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_git_sha": "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..835bf869 --- /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_git_sha": "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..0e8f15ba 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_git_sha: 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_git_sha: 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/Cargo.toml b/crates/subc-core/Cargo.toml index 206fd0c4..72c4d90f 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." @@ -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/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..916cc844 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,136 @@ 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: {}", 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") => { + 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 + .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(), + } +} + +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 +4001,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 +4014,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 +4097,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) => { @@ -4312,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/bin/fake-aft-stub.rs b/crates/subc-core/src/bin/fake-aft-stub.rs index f9410212..975d3311 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,9 +1308,31 @@ fn manifest( }, }, capabilities, + 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_git_sha: 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..af5efc32 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!("SUBC_BUILD_GIT_SHA")), + normalized_build_provenance(env!("SUBC_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 @@ -1013,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/control.rs b/crates/subc-core/src/control.rs index eb9d5948..8099891b 100644 --- a/crates/subc-core/src/control.rs +++ b/crates/subc-core/src/control.rs @@ -9,16 +9,17 @@ 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, manifest::{ - validate_hello_capability_grammar, CapabilityDeclarations, Concurrency, ModuleManifest, - ProviderRole, + validate_hello_capability_grammar, CapabilityDeclarations, Concurrency, ManifestProvenance, + ModuleManifest, ProviderRole, }, session::{ HealthReport, ModuleControlPush, ModuleControlRequest, ModuleControlRequestFromModule, @@ -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_git_sha: 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_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_git_sha, + 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. @@ -926,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) => { @@ -1226,6 +1302,9 @@ impl ControlHandler { ClientControlRequest::SupervisorRoutes { module_id } => { self.handle_supervisor_routes(frame, module_id) } + ClientControlRequest::SupervisorProvenance { module_id } => { + self.handle_supervisor_provenance(frame, module_id).await + } ClientControlRequest::SupervisorStderrTail { module_id, max_lines, @@ -2009,6 +2088,82 @@ 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, + }, + }); + } + 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 @@ -3315,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, @@ -4033,6 +4189,7 @@ mod tests { }, }, capabilities: None, + provenance: None, } } @@ -6622,6 +6779,39 @@ mod tests { assert_eq!(parse_error(&response[0])["code"], "unknown_control_op"); } + #[tokio::test] + async fn supervisor_provenance_rejects_unknown_exact_module() { + 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","module_id":"missing"}"#.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_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] async fn malformed_control_bodies_return_invalid_control_body() { let handler = ControlHandler::default(); 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..9643a4ab --- /dev/null +++ b/crates/subc-core/src/provenance.rs @@ -0,0 +1,399 @@ +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, 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 { + 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; + + // Followed metadata identifies the spawn-time target the supervisor executed, not a symlink name. + 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, + 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; + #[cfg(target_os = "linux")] + use subc_control::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/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/src/supervise.rs b/crates/subc-core/src/supervise.rs index 312e15ec..5205492a 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(), } @@ -455,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)] @@ -810,6 +824,7 @@ pub struct Supervisor { supervisor_handle: Option, health: HealthConfig, daemon_started_at_ms: u64, + provenance_probe: ExecutableIdentityProbe, } impl Supervisor { @@ -824,6 +839,7 @@ impl Supervisor { supervisor_handle: None, health: HealthConfig::default(), daemon_started_at_ms: unix_ms_now(), + provenance_probe: ExecutableIdentityProbe::default(), } } @@ -876,7 +892,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 +925,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 +970,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))) @@ -995,6 +1011,8 @@ impl Supervisor { TerminalRingConfig::default(), self.daemon_started_at_ms, ))), + #[cfg(test)] + test_seed_stale_facts_before_enable_spawn: false, } } @@ -1034,6 +1052,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 +1088,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 +1199,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 +1222,25 @@ impl SupervisedModule { }) } + 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 +1444,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(); } @@ -2344,6 +2384,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 { @@ -2614,8 +2811,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 +2898,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 +2965,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,9 +3094,21 @@ 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); })?; + #[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)); @@ -2911,8 +3117,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 +3171,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 +3204,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 +3350,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 +3380,9 @@ fn spawn_child( child, stderr_pump, stderr_ring: Arc::clone(ring), + spawned_at_ms, + spawned_from, + spawned_file_identity, }) } @@ -3216,7 +3425,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 +3861,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 +3936,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 +4001,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 +4112,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 +4139,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/catalog_update.rs b/crates/subc-core/tests/catalog_update.rs index 2629ecbb..63f76aeb 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_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()), + }; + 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/ck_cli.rs b/crates/subc-core/tests/ck_cli.rs index 465b61fa..e7a4c7fe 100644 --- a/crates/subc-core/tests/ck_cli.rs +++ b/crates/subc-core/tests/ck_cli.rs @@ -305,6 +305,215 @@ 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}"); + 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 = module_declared + .find(declared) + .unwrap_or_else(|| panic!("missing {declared:?} in:\n{stdout}")); + assert!( + 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!( + 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}"); + 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(); +} + +#[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; + 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,12 +826,40 @@ 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 { + 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(), } @@ -781,6 +1018,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( diff --git a/crates/subc-core/tests/closure.rs b/crates/subc-core/tests/closure.rs index ca09fde3..9f835b02 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,41 @@ 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()); + #[cfg(any(target_os = "linux", target_os = "macos"))] + assert!(matches!( + daemon.daemon_observed.running_image, + 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:?}"), + } +} + async fn assert_catalog_lists_every_archetype_with_only_thin_core_ops( server: &TestServer, used_channel0_ops: &mut BTreeSet<&'static str>, @@ -729,6 +765,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/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/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/provenance.rs b/crates/subc-core/tests/provenance.rs new file mode 100644 index 00000000..d600a5b9 --- /dev/null +++ b/crates/subc-core/tests/provenance.rs @@ -0,0 +1,345 @@ +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, + 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, +}; +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, + 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(10); +#[cfg(target_os = "linux")] +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( + "provenance-reported", + process_liveness.clone(), + 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) + .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", + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + ), + ("FAKE_AFT_WIRE_CRATE_VERSION", "0.0.0-forged"), + ("FAKE_AFT_STORE_SCHEMA_VERSION", "9999-forged-schema"), + ], + )) + .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!( + 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 { + panic!("the provenance block must remain a module declaration"); + }; + assert_eq!( + build.build_git_sha.as_deref(), + Some("ffffffffffffffffffffffffffffffffffffffff") + ); + assert_eq!( + build.build_lock_digest.as_deref(), + Some("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + ); + assert_eq!(build.wire_crate_version.as_deref(), Some("0.0.0-forged")); + assert_eq!( + build.store_schema_version.as_deref(), + Some("9999-forged-schema") + ); + 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(); + // 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_git_sha.as_deref(), + build_lock_digest.as_deref(), + wire_crate_version.as_deref(), + 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(); +} + +#[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 + } + )); +} + +#[cfg(target_os = "linux")] +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) + )) +} + +fn unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() + .try_into() + .unwrap() +} 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-core/tests/supervision.rs b/crates/subc-core/tests/supervision.rs index be8a1739..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; @@ -55,6 +61,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; @@ -79,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; @@ -141,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:?}" @@ -198,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) @@ -478,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. diff --git a/crates/subc-mcp/src/main.rs b/crates/subc-mcp/src/main.rs index 0b6b1271..b232683b 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_git_sha: 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_git_sha": "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..d790285d 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,117 @@ pub struct CapabilityDeclarations { pub must_never_reach: Vec, } +#[derive(Serialize, Debug, Clone, PartialEq, Eq)] +pub struct ManifestProvenance { + #[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, + #[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, +} + +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.is_empty() { + return Err(ManifestProvenanceError::new( + field, + value, + "must not be empty", + )); + } + 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)] @@ -769,6 +885,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..1d838085 --- /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_git_sha": "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..56f23a48 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,132 @@ 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_git_sha": "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_git_sha", + "build_lock_digest", + "wire_crate_version", + "store_schema_version", + ] { + let mut provenance = ManifestProvenance { + 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_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, + _ => 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 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 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) + .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 +418,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_git_sha: 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 +484,7 @@ fn module_manifest(module_id: &str) -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } @@ -378,6 +522,7 @@ fn management_surface_manifest(description: Option<&str>) -> ModuleManifest { }, }, capabilities: None, + provenance: None, } } diff --git a/docs/fleet-surface.md b/docs/fleet-surface.md index d856c56f..b231b8d2 100644 --- a/docs/fleet-surface.md +++ b/docs/fleet-surface.md @@ -33,11 +33,43 @@ 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. 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` +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. 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) Indexed code perception + editing for agent harnesses. diff --git a/docs/subc-control-protocol.md b/docs/subc-control-protocol.md index c16d322e..8f37426a 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,86 @@ 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. 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 +`SUBC_BUILD_GIT_SHA` and `SUBC_BUILD_LOCK_DIGEST`; its exact grammar is: + +```text +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 +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. 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 { #[serde(rename = "route.status")] RouteStatus { route_channel: u16, status: String }, // opaque, cached verbatim