From 2c080c3ca9a21312d077d949d2f2726848f9e392 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Narajala Date: Mon, 2 Nov 2026 18:21:02 +0000 Subject: [PATCH 1/2] feat(sandbox): add opt-in process lifecycle events Signed-off-by: Vineeth Sai Narajala --- crates/openshell-core/src/settings.rs | 16 ++ .../openshell-isolation-interface/src/lib.rs | 2 + crates/openshell-ocsf/src/format/shorthand.rs | 82 +++++++- crates/openshell-ocsf/src/objects/process.rs | 37 ++++ crates/openshell-ocsf/tests/roundtrip.rs | 9 +- crates/openshell-sandbox-backend/Cargo.toml | 1 + crates/openshell-sandbox-backend/README.md | 10 + .../src/boundary_protocol.rs | 37 ++++ .../openshell-sandbox-backend/src/protobuf.rs | 135 ++++++++++++- .../openshell-sandbox-backend/src/runtime.rs | 177 ++++++++++++++++- .../src/runtime/test_support.rs | 8 +- crates/openshell-sandbox/README.md | 12 ++ crates/openshell-sandbox/src/boundary_exec.rs | 39 ++++ crates/openshell-sandbox/src/boundary_io.rs | 8 + .../openshell-sandbox/src/boundary_server.rs | 48 ++++- crates/openshell-sandbox/src/delegated.rs | 54 ++++-- crates/openshell-sandbox/src/lib.rs | 2 + crates/openshell-sandbox/src/native_api.rs | 6 + crates/openshell-sandbox/src/process_audit.rs | 180 ++++++++++++++++++ .../src/backend_setup/tests.rs | 3 + .../src/config_runtime.rs | 21 ++ crates/openshell-supervisor/src/lib.rs | 18 +- docs/observability/accessing-logs.mdx | 9 +- docs/observability/logging.mdx | 35 ++++ proto/sandbox_protocol.proto | 15 ++ skills/openshell-cli/SKILL.md | 2 + 26 files changed, 935 insertions(+), 31 deletions(-) create mode 100644 crates/openshell-sandbox/src/process_audit.rs diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index f819d8ab38..00035e34b0 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -84,6 +84,10 @@ impl RegisteredSetting { /// still applies when this flag is on. pub const AGENT_POLICY_PROPOSALS_ENABLED_KEY: &str = "agent_policy_proposals_enabled"; +/// Opt in to launch and exit events for processes started by the Linux +/// sandbox boundary. The value is captured when the sandbox starts. +pub const PROCESS_ACTIVITY_ENABLED_KEY: &str = "process_activity_enabled"; + /// Approval mode for agent-authored policy proposals. /// /// `"manual"` (the default when unset): every proposal lands in the draft @@ -115,6 +119,11 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ kind: SettingValueKind::Bool, allowed_string_values: None, }, + RegisteredSetting { + key: PROCESS_ACTIVITY_ENABLED_KEY, + kind: SettingValueKind::Bool, + allowed_string_values: None, + }, // Deprecated and ignored: OpenShell emits OCSF 1.8.0 only. Still // registered so existing values load; the supervisor reports a set value. RegisteredSetting { @@ -187,6 +196,13 @@ mod tests { assert!(setting_for_key("providers_v2_enabled").is_none()); } + #[test] + fn process_activity_is_an_opt_in_boolean_setting() { + let setting = setting_for_key("process_activity_enabled") + .expect("process activity setting should be registered"); + assert_eq!(setting.kind, SettingValueKind::Bool); + } + // ---- RegisteredSetting::validate_string_value ---- #[test] diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index 446f6cc244..505c38ffec 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -33,6 +33,8 @@ pub struct AgentSpec { /// Whether the supervisor serves the OTLP relay for this sandbox. The /// boundary points workload SDKs at the relay only while it is active. pub otlp_relay_active: bool, + /// Whether the boundary emits lifecycle events for processes it starts. + pub process_activity_enabled: bool, } pub mod contract; diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 5b80f29205..2a817514b8 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -517,7 +517,45 @@ impl OcsfEvent { Self::ProcessActivity(e) => { let activity = e.base.activity_name.to_uppercase(); - let proc_str = format!("{}({})", e.process.name, e.process.pid); + let proc_str = format!( + "{}({})", + escape_context_field(&truncate_with_ellipsis(&e.process.name, MAX_REASON_LEN)), + e.process.pid + ); + let parent_ctx = e + .process + .parent_process + .as_ref() + .map(|parent| format!(" [ppid:{}]", parent.pid)) + .unwrap_or_default(); + let start_ctx = e + .process + .created_time + .map(|time| format!(" [start:{time}]")) + .unwrap_or_default(); + let uid_ctx = e + .process + .uid + .as_ref() + .map(|uid| { + format!( + " [process_uid:{}]", + escape_context_field(&truncate_with_ellipsis(uid, MAX_REASON_LEN)) + ) + }) + .unwrap_or_default(); + let parent_uid_ctx = e + .process + .parent_process + .as_ref() + .and_then(|parent| parent.uid.as_ref()) + .map(|uid| { + format!( + " [parent_uid:{}]", + escape_context_field(&truncate_with_ellipsis(uid, MAX_REASON_LEN)) + ) + }) + .unwrap_or_default(); let exit_ctx = e .exit_code .map(|c| format!(" [exit:{c}]")) @@ -529,7 +567,17 @@ impl OcsfEvent { .map(|c| format!(" [cmd:{c}]")) .unwrap_or_default(); - format!("PROC:{activity} {sev} {proc_str}{exit_ctx}{cmd_ctx}") + let signal_ctx = e + .base + .unmapped + .as_ref() + .and_then(|fields| fields.get("signal")) + .and_then(serde_json::Value::as_i64) + .map(|signal| format!(" [signal:{signal}]")) + .unwrap_or_default(); + format!( + "PROC:{activity} {sev} {proc_str}{parent_ctx}{start_ctx}{uid_ctx}{parent_uid_ctx}{exit_ctx}{signal_ctx}{cmd_ctx}" + ) } Self::DetectionFinding(e) => { @@ -1236,6 +1284,36 @@ mod tests { assert_eq!(shorthand, "PROC:TERMINATE [INFO] python3(42) [exit:0]"); } + #[test] + fn process_activity_shorthand_shows_parent_start_and_signal() { + let mut base = base( + 1007, + "Process Activity", + 1, + "System Activity", + 2, + "Terminate", + ); + base.unmapped = Some(serde_json::json!({"signal": 9, "other": "hidden"})); + let event = OcsfEvent::ProcessActivity(ProcessActivityEvent { + base, + process: Process::new("sleep\nspoof", 42) + .with_uid("42:1234") + .with_created_time(1_234_000) + .with_parent(Process::new("openshell-sandbox", 1)), + actor: None, + launch_type: None, + exit_code: None, + action: None, + disposition: None, + }); + + assert_eq!( + event.format_shorthand(), + "PROC:TERMINATE [INFO] sleep\\u{a}spoof(42) [ppid:1] [start:1234000] [process_uid:42:1234] [signal:9]" + ); + } + #[test] fn test_detection_finding_shorthand() { let event = OcsfEvent::DetectionFinding(DetectionFindingEvent { diff --git a/crates/openshell-ocsf/src/objects/process.rs b/crates/openshell-ocsf/src/objects/process.rs index bb51781217..a00dcb3621 100644 --- a/crates/openshell-ocsf/src/objects/process.rs +++ b/crates/openshell-ocsf/src/objects/process.rs @@ -16,6 +16,14 @@ pub struct Process { /// Process ID. pub pid: i64, + /// Stable process instance ID, when the source can identify one. + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, + + /// Process start time in Unix epoch milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_time: Option, + /// Full command line. #[serde(skip_serializing_if = "Option::is_none")] pub cmd_line: Option, @@ -32,6 +40,8 @@ impl Process { Self { name: name.to_string(), pid, + uid: None, + created_time: None, cmd_line: None, parent_process: None, } @@ -44,6 +54,20 @@ impl Process { self } + /// Set a stable identifier for this process instance. + #[must_use] + pub fn with_uid(mut self, uid: impl Into) -> Self { + self.uid = Some(uid.into()); + self + } + + /// Set the process start time in Unix epoch milliseconds. + #[must_use] + pub fn with_created_time(mut self, time_ms: i64) -> Self { + self.created_time = Some(time_ms); + self + } + /// Set the parent process. #[must_use] pub fn with_parent(mut self, parent: Self) -> Self { @@ -130,6 +154,19 @@ mod tests { assert_eq!(parent.pid, 1); } + #[test] + fn process_uid_distinguishes_reused_pids() { + let first = Process::new("sleep", 42) + .with_uid("42:100") + .with_created_time(1_000); + let second = Process::new("sleep", 42) + .with_uid("42:200") + .with_created_time(2_000); + assert_ne!(first.uid, second.uid); + assert_eq!(serde_json::to_value(first).unwrap()["uid"], "42:100"); + assert_eq!(serde_json::to_value(second).unwrap()["uid"], "42:200"); + } + #[test] fn test_process_from_bypass() { let proc = Process::from_bypass("node", "1234", "bash -> node"); diff --git a/crates/openshell-ocsf/tests/roundtrip.rs b/crates/openshell-ocsf/tests/roundtrip.rs index 6368d55386..8706fd5eff 100644 --- a/crates/openshell-ocsf/tests/roundtrip.rs +++ b/crates/openshell-ocsf/tests/roundtrip.rs @@ -124,8 +124,14 @@ fn process_activity_round_trips() { .activity(ActivityId::Open) .process( Process::new("/usr/bin/python3", 4713) + .with_uid("4713:123456") + .with_created_time(1_793_620_000_120) .with_cmd_line("python3 -m pytest") - .with_parent(Process::new("/bin/sh", 4701)), + .with_parent( + Process::new("/bin/sh", 4701) + .with_uid("4701:123400") + .with_created_time(1_793_619_999_560), + ), ) .actor_process(Process::new("openshell-sandbox", 4700)) .launch_type(LaunchTypeId::Other) @@ -136,6 +142,7 @@ fn process_activity_round_trips() { .build(); assert_round_trips("process_activity", &event); + openshell_ocsf_validate::validate_event(&event.to_json().unwrap()).unwrap(); } #[test] diff --git a/crates/openshell-sandbox-backend/Cargo.toml b/crates/openshell-sandbox-backend/Cargo.toml index 8e8ff7cb05..2d104a0ba4 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -17,6 +17,7 @@ test-support = [] openshell-crypto = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false, features = ["jwt"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } +openshell-ocsf = { path = "../openshell-ocsf" } async-trait = "0.1" bytes = { workspace = true } hyper-util = { workspace = true } diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 8deb207f5d..ffaf52ea66 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -10,6 +10,16 @@ and produces the ready handle; starting consumes that ready handle. Both the in-tree Linux runtime and external backends implement the generated services. Linux process helpers remain internal to the runtime server. +## Process activity + +When `AgentSpec.process_activity_enabled` is set, the Linux runtime includes +the directly started process's PID, parent PID, start instance ID, and start +time in `Started`, `ExecStarted`, and takeover snapshots. These optional fields +keep older boundary implementations compatible. The supervisor-side client +uses them to emit OCSF launch and exit events through the existing supervisor +log path. `BoundaryProcess::wait` emits an exit only once even if callers +repeat the wait. The protocol carries no arguments or environment. + ## Boundary startup and routing The supervisor uses the generated `IsolationBackend` control service to diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 724c9e1faa..59801f7023 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -1003,6 +1003,8 @@ pub enum Response { process_id: String, provider_env_revision: u64, provider_env_generation: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + process_identity: Option, }, ProviderEnvironmentUpdated { revision: u64, @@ -1022,6 +1024,8 @@ pub enum Response { ExecStarted { process_id: String, pty: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + process_identity: Option, }, Resized, LoopbackConnected, @@ -1067,6 +1071,21 @@ pub struct ProcessSnapshotWire { pub terminal: bool, pub status: Option, pub retained_output: OutputWindowWire, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_identity: Option, +} + +/// Identity captured by the runtime before a process can be reaped. Arguments +/// are intentionally excluded from this protocol and from audit events. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessIdentityWire { + pub name: String, + pub pid: u32, + pub uid: Option, + pub created_time_ms: Option, + pub parent_pid: Option, + pub parent_uid: Option, + pub parent_created_time_ms: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -1263,6 +1282,9 @@ pub struct AgentSpecWire { /// relay is unchanged. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub otlp_relay_active: bool, + /// Omitted while false to preserve existing request digests. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub process_activity_enabled: bool, } impl From for AgentSpecWire { @@ -1274,6 +1296,7 @@ impl From for AgentSpecWire { timeout_secs: spec.timeout_secs, interactive: spec.interactive, otlp_relay_active: spec.otlp_relay_active, + process_activity_enabled: spec.process_activity_enabled, } } } @@ -1697,6 +1720,7 @@ mod tests { timeout_secs: 0, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }; let encoded = serde_json::to_value(&spec).unwrap(); assert!( @@ -1710,6 +1734,17 @@ mod tests { let decoded: AgentSpecWire = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); assert!(decoded.otlp_relay_active); + + assert!( + serde_json::to_value(&spec) + .unwrap() + .get("process_activity_enabled") + .is_none() + ); + spec.process_activity_enabled = true; + let decoded: AgentSpecWire = + serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); + assert!(decoded.process_activity_enabled); } #[test] @@ -1728,6 +1763,7 @@ mod tests { timeout_secs: 5, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, ca_cert: Some("test certificate".to_string()), ca_bundle: Some("test bundle".to_string()), @@ -1886,6 +1922,7 @@ mod tests { timeout_secs: 5, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, ca_cert: Some("A".repeat(16 * 1024)), ca_bundle: Some("B".repeat(400 * 1024)), diff --git a/crates/openshell-sandbox-backend/src/protobuf.rs b/crates/openshell-sandbox-backend/src/protobuf.rs index 2f0ebc80c4..797fa9fb7e 100644 --- a/crates/openshell-sandbox-backend/src/protobuf.rs +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -87,6 +87,34 @@ impl WireMessage for w::ExitStatusWire { }) } } + +impl WireMessage for w::ProcessIdentityWire { + type Proto = p::ProcessIdentity; + + fn to_proto(&self) -> Result { + Ok(p::ProcessIdentity { + name: self.name.clone(), + pid: self.pid, + uid: self.uid.clone(), + created_time_ms: self.created_time_ms, + parent_pid: self.parent_pid, + parent_uid: self.parent_uid.clone(), + parent_created_time_ms: self.parent_created_time_ms, + }) + } + + fn from_proto(value: Self::Proto) -> Result { + Ok(Self { + name: value.name, + pid: value.pid, + uid: value.uid, + created_time_ms: value.created_time_ms, + parent_pid: value.parent_pid, + parent_uid: value.parent_uid, + parent_created_time_ms: value.parent_created_time_ms, + }) + } +} impl WireMessage for w::SandboxPolicyWire { type Proto = p::SandboxPolicy; fn to_proto(&self) -> Result { @@ -173,6 +201,7 @@ impl WireMessage for w::AgentSpecWire { }), interactive: self.interactive, otlp_relay_active: self.otlp_relay_active, + process_activity_enabled: self.process_activity_enabled, }) } fn from_proto(v: Self::Proto) -> Result { @@ -187,6 +216,7 @@ impl WireMessage for w::AgentSpecWire { timeout_secs: u64::try_from(timeout.seconds).map_err(invalid)?, interactive: v.interactive, otlp_relay_active: v.otlp_relay_active, + process_activity_enabled: v.process_activity_enabled, }) } } @@ -392,6 +422,11 @@ impl WireMessage for w::SessionSnapshotWire { next_sequence: v.retained_output.next_sequence, truncated: v.retained_output.truncated, }), + process_identity: v + .process_identity + .as_ref() + .map(WireMessage::to_proto) + .transpose()?, }) }) .collect::>()?, @@ -424,6 +459,10 @@ impl WireMessage for w::SessionSnapshotWire { next_sequence: output.next_sequence, truncated: output.truncated, }, + process_identity: v + .process_identity + .map(w::ProcessIdentityWire::from_proto) + .transpose()?, }) }) .collect::>()?, @@ -640,10 +679,15 @@ impl WireMessage for w::ResponseEnvelope { process_id, provider_env_revision, provider_env_generation, + process_identity, } => P::Started(p::Started { process_id: process_id.clone(), provider_env_revision: *provider_env_revision, provider_env_generation: *provider_env_generation, + process_identity: process_identity + .as_ref() + .map(WireMessage::to_proto) + .transpose()?, }), W::ProviderEnvironmentUpdated { revision, @@ -663,9 +707,17 @@ impl WireMessage for w::ResponseEnvelope { W::Signaled => P::Signaled(p::Signaled {}), W::Terminated => P::Terminated(p::Terminated {}), W::BoundaryTerminated => P::BoundaryTerminated(p::BoundaryTerminated {}), - W::ExecStarted { process_id, pty } => P::ExecStarted(p::ExecStarted { + W::ExecStarted { + process_id, + pty, + process_identity, + } => P::ExecStarted(p::ExecStarted { process_id: process_id.clone(), pty: *pty, + process_identity: process_identity + .as_ref() + .map(WireMessage::to_proto) + .transpose()?, }), W::Resized => P::Resized(p::Resized {}), W::LoopbackConnected => P::LoopbackConnected(p::LoopbackConnected {}), @@ -717,6 +769,10 @@ impl WireMessage for w::ResponseEnvelope { process_id: v.process_id, provider_env_revision: v.provider_env_revision, provider_env_generation: v.provider_env_generation, + process_identity: v + .process_identity + .map(w::ProcessIdentityWire::from_proto) + .transpose()?, }, P::ProviderEnvironmentUpdated(v) => W::ProviderEnvironmentUpdated { revision: v.revision, @@ -735,6 +791,10 @@ impl WireMessage for w::ResponseEnvelope { P::ExecStarted(v) => W::ExecStarted { process_id: v.process_id, pty: v.pty, + process_identity: v + .process_identity + .map(w::ProcessIdentityWire::from_proto) + .transpose()?, }, P::Resized(_) => W::Resized, P::LoopbackConnected(_) => W::LoopbackConnected, @@ -819,6 +879,74 @@ where mod tests { use super::*; + #[test] + fn process_identity_survives_start_exec_and_legacy_responses() { + let identity = w::ProcessIdentityWire { + name: "/bin/true".to_string(), + pid: 42, + uid: Some("42:1234".to_string()), + created_time_ms: Some(1_793_620_000_000), + parent_pid: Some(1), + parent_uid: Some("1:2".to_string()), + parent_created_time_ms: Some(1_793_619_999_000), + }; + let start = w::ResponseEnvelope { + request_id: "start".to_string(), + response: w::Response::Started { + process_id: "generation:main:0".to_string(), + provider_env_revision: 0, + provider_env_generation: 0, + process_identity: Some(identity.clone()), + }, + }; + let decoded = w::ResponseEnvelope::from_proto(start.to_proto().unwrap()).unwrap(); + assert_eq!(decoded, start); + + let exec = w::ResponseEnvelope { + request_id: "exec".to_string(), + response: w::Response::ExecStarted { + process_id: "generation:exec:1".to_string(), + pty: false, + process_identity: Some(identity.clone()), + }, + }; + let decoded = w::ResponseEnvelope::from_proto(exec.to_proto().unwrap()).unwrap(); + assert_eq!(decoded, exec); + + let mut old = exec.to_proto().unwrap(); + if let Some(p::response_envelope::Response::ExecStarted(value)) = &mut old.response { + value.process_identity = None; + } + assert!(matches!( + w::ResponseEnvelope::from_proto(old).unwrap().response, + w::Response::ExecStarted { + process_identity: None, + .. + } + )); + + let snapshot = w::SessionSnapshotWire { + generation: "generation".to_string(), + processes: vec![w::ProcessSnapshotWire { + process_id: "generation:exec:1".to_string(), + kind: w::ProcessKindWire::Exec, + terminal: false, + status: Some(w::ExitStatusWire::Exited(0)), + retained_output: w::OutputWindowWire { + first_sequence: 0, + next_sequence: 0, + truncated: false, + }, + process_identity: Some(identity), + }], + bound_policy: None, + }; + assert_eq!( + w::SessionSnapshotWire::from_proto(snapshot.to_proto().unwrap()).unwrap(), + snapshot + ); + } + #[test] fn request_validation_rejects_missing_context_and_unknown_signal() { let id = uuid::Uuid::new_v4().to_string(); @@ -934,6 +1062,7 @@ mod tests { timeout_secs: 0, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }; let inactive = spec.to_proto().unwrap(); assert_eq!(w::AgentSpecWire::from_proto(inactive).unwrap(), spec); @@ -941,6 +1070,10 @@ mod tests { spec.otlp_relay_active = true; let decoded = w::AgentSpecWire::from_proto(spec.to_proto().unwrap()).unwrap(); assert!(decoded.otlp_relay_active); + + spec.process_activity_enabled = true; + let decoded = w::AgentSpecWire::from_proto(spec.to_proto().unwrap()).unwrap(); + assert!(decoded.process_activity_enabled); } #[test] diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 9ea72d396c..b792b22a84 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -28,6 +28,10 @@ use openshell_isolation_interface::contract::{ MediationTiming, PendingDnsQuery, PendingTcpOpen, ProviderEnvironmentInstallation, ResolvedWorkloadIdentity, SandboxContext, TcpOpenDecision, TcpOpenDenial, }; +use openshell_ocsf::{ + ActionId, ActivityId, DispositionId, LaunchTypeId, OcsfEvent, Process, ProcessActivityBuilder, + SeverityId, StatusId, ocsf_emit, +}; use rustls::pki_types::{CertificateDer, pem::PemObject}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[cfg(unix)] @@ -70,6 +74,79 @@ const NETWORK_STREAM_WAIT_WARNING: Duration = Duration::from_secs(5); /// after a takeover retires its connection. const ADOPTED_ATTACHMENT_RELEASE_TIMEOUT: Duration = Duration::from_secs(10); +fn audited_process(identity: &crate::boundary_protocol::ProcessIdentityWire) -> Process { + let mut process = Process::new(&identity.name, i64::from(identity.pid)); + if let Some(uid) = &identity.uid { + process = process.with_uid(uid.clone()); + } + if let Some(time_ms) = identity.created_time_ms { + process = process.with_created_time(time_ms); + } + if let Some(parent_pid) = identity.parent_pid { + let mut parent = Process::new("unknown", i64::from(parent_pid)); + if let Some(uid) = &identity.parent_uid { + parent = parent.with_uid(uid.clone()); + } + if let Some(time_ms) = identity.parent_created_time_ms { + parent = parent.with_created_time(time_ms); + } + process = process.with_parent(parent); + } + process +} + +fn process_start_event(identity: &crate::boundary_protocol::ProcessIdentityWire) -> OcsfEvent { + ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .launch_type(LaunchTypeId::Spawn) + .process(audited_process(identity)) + .actor_process(Process::new( + "openshell-supervisor", + i64::from(std::process::id()), + )) + .message(format!("Process started: pid={}", identity.pid)) + .build() +} + +fn process_exit_event( + identity: &crate::boundary_protocol::ProcessIdentityWire, + status: ExitStatusWire, +) -> OcsfEvent { + let mut builder = ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Close) + .severity(SeverityId::Informational) + .process(audited_process(identity)) + .actor_process(Process::new( + "openshell-supervisor", + i64::from(std::process::id()), + )); + if let ExitStatusWire::Exited(code) = status { + builder = builder.exit_code(code); + } + let mut event = builder.build(); + if let ExitStatusWire::Signaled(signal) = status + && let OcsfEvent::ProcessActivity(process_event) = &mut event + { + process_event.base.unmapped = Some(serde_json::json!({"signal": signal})); + } + event +} + +fn emit_process_start(identity: &crate::boundary_protocol::ProcessIdentityWire) { + ocsf_emit!(process_start_event(identity)); +} + +fn emit_process_exit( + identity: &crate::boundary_protocol::ProcessIdentityWire, + status: ExitStatusWire, +) { + ocsf_emit!(process_exit_event(identity, status)); +} + fn begin_recovery_window( deadline: &mut Option, failure_time: tokio::time::Instant, @@ -562,9 +639,15 @@ impl OpenShellRuntimeBackend { #[derive(Clone, Debug, Default)] struct RecoveredProcesses { /// The canonical process, when the previous owner started it. - main: Option, + main: Option<( + String, + Option, + )>, /// Exec processes that have not exited. - execs: Vec, + execs: Vec<( + String, + Option, + )>, } impl RecoveredProcesses { @@ -573,9 +656,14 @@ impl RecoveredProcesses { let mut recovered = Self::default(); for process in &snapshot.processes { match process.kind { - ProcessKindWire::Main => recovered.main = Some(process.process_id.clone()), + ProcessKindWire::Main => { + recovered.main = + Some((process.process_id.clone(), process.process_identity.clone())); + } ProcessKindWire::Exec if process.status.is_none() => { - recovered.execs.push(process.process_id.clone()); + recovered + .execs + .push((process.process_id.clone(), process.process_identity.clone())); } ProcessKindWire::Exec => {} } @@ -809,16 +897,21 @@ impl ReadyBoundary { /// without launching anything. pub async fn start_agent(self: Box) -> Result, BackendError> { if let Some(RecoveredProcesses { - main: Some(process_id), + main: Some((process_id, process_identity)), execs, }) = &self.recovered { - let adopted = |process_id: &String| { + let adopted = |(process_id, process_identity): &( + String, + Option, + )| { Arc::new(BoundaryProcess { exec: true, adopted: true, client: self.client.clone(), process_id: process_id.clone(), + process_identity: process_identity.clone(), + exit_emitted: AtomicBool::new(false), }) }; return Ok(Box::new(RunningBoundary { @@ -827,6 +920,8 @@ impl ReadyBoundary { adopted: true, client: self.client.clone(), process_id: process_id.clone(), + process_identity: process_identity.clone(), + exit_emitted: AtomicBool::new(false), }), adopted_execs: execs.iter().map(adopted).collect(), exec: Arc::new(BoundaryExec { @@ -883,16 +978,22 @@ impl ReadyBoundary { let Response::Started { process_id, provider_env_generation, + process_identity, .. } = response else { return Err(unexpected_response("started", &response)); }; + if let Some(identity) = &process_identity { + emit_process_start(identity); + } let process = Arc::new(BoundaryProcess { exec: false, adopted: false, client: self.client.clone(), process_id, + process_identity, + exit_emitted: AtomicBool::new(false), }); Ok(Box::new(RunningBoundary { process, @@ -980,6 +1081,8 @@ pub struct BoundaryProcess { adopted: bool, client: Arc, process_id: String, + process_identity: Option, + exit_emitted: AtomicBool, } impl BoundaryProcess { @@ -1028,6 +1131,11 @@ impl BoundaryProcess { let Response::Exited { status } = response else { return Err(unexpected_response("exited", &response)); }; + if let Some(identity) = &self.process_identity + && !self.exit_emitted.swap(true, Ordering::AcqRel) + { + emit_process_exit(identity, status); + } Ok(status.into()) } @@ -1266,9 +1374,17 @@ async fn open_exec_session( spec: ExecSpecWire::from(spec), }) .await?; - let Response::ExecStarted { process_id, pty } = response else { + let Response::ExecStarted { + process_id, + pty, + process_identity, + } = response + else { return Err(unexpected_response("exec_started", &response)); }; + if let Some(identity) = &process_identity { + emit_process_start(identity); + } let (network_reader, network_writer) = tokio::io::split(stream); let (stdin, stdin_pump) = tokio::io::duplex(64 * 1024); let (stdout, stdout_pump) = tokio::io::duplex(64 * 1024); @@ -1287,7 +1403,18 @@ async fn open_exec_session( adopted: false, client: client.clone(), process_id: process_id.clone(), + process_identity, + exit_emitted: AtomicBool::new(false), }); + if process.process_identity.is_some() { + // SSH and CLI exec can consume the output stream's terminal status + // without calling wait on this handle. Observe the canonical status + // separately so the exit audit record is still emitted. + let observed = process.clone(); + tokio::spawn(async move { + let _ = observed.wait().await; + }); + } let terminal: Option> = if pty { Some(Arc::new(BoundaryTerminal { client, process_id })) } else { @@ -3066,6 +3193,35 @@ mod tests { const TEST_BOUNDARY_HANDLE: &str = "opaque-test-boundary"; + #[test] + fn process_activity_events_preserve_instance_and_redact_arguments() { + let identity = crate::boundary_protocol::ProcessIdentityWire { + name: "/bin/sh".to_string(), + pid: 57, + uid: Some("57:123456".to_string()), + created_time_ms: Some(1_793_620_000_120), + parent_pid: Some(1), + parent_uid: Some("1:34".to_string()), + parent_created_time_ms: Some(1_793_619_999_000), + }; + let started = process_start_event(&identity).to_json().unwrap(); + let exited = process_exit_event(&identity, ExitStatusWire::Exited(7)) + .to_json() + .unwrap(); + assert_eq!(started["process"], exited["process"]); + assert_eq!(started["process"]["uid"], "57:123456"); + assert_eq!(started["process"]["parent_process"]["uid"], "1:34"); + assert!(started["process"].get("cmd_line").is_none()); + assert_eq!(exited["exit_code"], 7); + + let signaled = process_exit_event(&identity, ExitStatusWire::Signaled(9)) + .to_json() + .unwrap(); + assert_eq!(signaled["process"], started["process"]); + assert_eq!(signaled["unmapped"]["signal"], 9); + assert!(signaled.get("exit_code").is_none()); + } + #[derive(Clone)] struct TestIsolationBackend { metadata: Option, @@ -3700,6 +3856,7 @@ mod tests { Request::Exec { .. } => Response::ExecStarted { process_id: "test-generation:exec:1".to_string(), pty: false, + process_identity: None, }, Request::AttachProcess { .. } => { Response::ProcessAttached { terminal: false } @@ -3725,6 +3882,7 @@ mod tests { process_id: "test-generation:main:0".to_string(), provider_env_revision, provider_env_generation: provider_environment_generation, + process_identity: None, }, Request::AcceptNetwork => Response::Error { kind: crate::boundary_protocol::BoundaryErrorKind::Unavailable, @@ -4301,6 +4459,7 @@ mod tests { timeout_secs: 5, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, identity: ResolvedWorkloadIdentity::new( 10_001, @@ -4964,6 +5123,7 @@ mod tests { kind: crate::boundary_protocol::ProcessKindWire::Main, terminal: false, status: None, + process_identity: None, retained_output: crate::boundary_protocol::OutputWindowWire { first_sequence: 0, next_sequence: 9, @@ -4975,6 +5135,7 @@ mod tests { kind: crate::boundary_protocol::ProcessKindWire::Exec, terminal: false, status: None, + process_identity: None, retained_output: crate::boundary_protocol::OutputWindowWire { first_sequence: 0, next_sequence: 0, @@ -4986,6 +5147,7 @@ mod tests { kind: crate::boundary_protocol::ProcessKindWire::Exec, terminal: false, status: Some(ExitStatusWire::Exited(0)), + process_identity: None, retained_output: crate::boundary_protocol::OutputWindowWire { first_sequence: 0, next_sequence: 4, @@ -5045,6 +5207,7 @@ mod tests { kind, terminal: false, status: None, + process_identity: None, retained_output: crate::boundary_protocol::OutputWindowWire { first_sequence: 0, next_sequence: 0, diff --git a/crates/openshell-sandbox-backend/src/runtime/test_support.rs b/crates/openshell-sandbox-backend/src/runtime/test_support.rs index 79c36e2959..388efa2ef1 100644 --- a/crates/openshell-sandbox-backend/src/runtime/test_support.rs +++ b/crates/openshell-sandbox-backend/src/runtime/test_support.rs @@ -287,7 +287,11 @@ impl Peer { self.terminals.lock().unwrap().insert(process_id.clone(), t); } io = Some((a.stdin, a.stdout, a.stderr, a.process, a.output_status)); - Response::ExecStarted { process_id, pty } + Response::ExecStarted { + process_id, + pty, + process_identity: None, + } } Request::LoopbackConnect { host, port } => { relay = Some( @@ -777,6 +781,8 @@ pub fn process(process: Arc) -> Arc process_id: "main".into(), exec: false, adopted: false, + process_identity: None, + exit_emitted: std::sync::atomic::AtomicBool::new(false), }) } pub fn terminal(terminal: Arc) -> Arc { diff --git a/crates/openshell-sandbox/README.md b/crates/openshell-sandbox/README.md index 72d01b73c7..14c3b894b0 100644 --- a/crates/openshell-sandbox/README.md +++ b/crates/openshell-sandbox/README.md @@ -10,6 +10,18 @@ the lower-level mechanisms used by that orchestration and the boundary server. Unsafe kernel ABI, process-launch, and descriptor-ownership operations stay in this runtime crate behind safe APIs where their contracts can be enforced. +When the supervisor passes `process_activity_enabled` in the admitted agent +spec, the boundary emits OCSF Process Activity launch and termination events +for its canonical child and for each exec or SSH child. It captures the child +PID, parent PID, and `/proc//stat` start tick before waiting, then retains +that identity for the exit record and sends it in the existing boundary +responses. The supervisor emits matching events to its gateway log path. +`process.uid` is `pid:start_ticks` and +`process.created_time` is Unix milliseconds derived from the kernel boot time. +The events omit argv. The setting defaults off, while the existing canonical +launch event remains enabled. This path does not observe forks or in-place execs +inside a workload child. + The network broker retains source socket descriptors only while preparing a workload endpoint. A successful connection or `listen` releases that reference; the kernel then tracks the lifetime through workload-owned descriptors. Native diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index d823fef977..39b1f63b2e 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -25,6 +25,7 @@ use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ BackendError, BoundaryExitStatus, BoundaryInput, BoundaryOutput, BoundarySignal, ExecSpec, }; +use openshell_ocsf::ocsf_emit; /// The sandbox executor. Every spawn reuses the same admitted policy and /// execution-environment controls while taking a fresh provider credential @@ -38,6 +39,7 @@ pub struct LocalBoundaryExec { user_environment: HashMap, runtime: Arc, otlp_relay_active: bool, + process_activity_enabled: bool, #[cfg(target_os = "linux")] launcher: crate::linux::workload_launcher::WorkloadLauncher, } @@ -54,6 +56,7 @@ impl LocalBoundaryExec { user_environment: HashMap, runtime: Arc, otlp_relay_active: bool, + process_activity_enabled: bool, #[cfg(target_os = "linux")] launcher: crate::linux::workload_launcher::WorkloadLauncher, ) -> Self { Self { @@ -64,6 +67,7 @@ impl LocalBoundaryExec { user_environment, runtime, otlp_relay_active, + process_activity_enabled, #[cfg(target_os = "linux")] launcher, } @@ -226,6 +230,9 @@ impl LocalBoundaryExec { fn spawn_piped(&self, spec: &ExecSpec) -> Result { self.runtime.ensure_active()?; let mut command = self.command(spec)?; + let audit_program = self + .process_activity_enabled + .then(|| command.get_program().to_string_lossy().into_owned()); command .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -275,6 +282,11 @@ impl LocalBoundaryExec { let managed_child = child_registry.register(pid); #[cfg(target_os = "linux")] drop(child_registry); + let audit = + audit_program.map(|program| crate::process_audit::ProcessAudit::capture(&program, pid)); + if let Some(audit) = &audit { + ocsf_emit!(audit.launch_event()); + } let stdin = child.stdin.take().map(|file| -> BoundaryInput { let fd: OwnedFd = file.into(); Box::new(tokio::fs::File::from_std(std::fs::File::from(fd))) @@ -297,6 +309,7 @@ impl LocalBoundaryExec { self.runtime.clone(), process_terminal, signal_lock, + audit, #[cfg(target_os = "linux")] managed_child, )); @@ -339,6 +352,9 @@ impl LocalBoundaryExec { .try_clone() .map_err(|error| BackendError::Process(error.to_string()))?; let mut command = self.command(spec)?; + let audit_program = self + .process_activity_enabled + .then(|| command.get_program().to_string_lossy().into_owned()); command.stdin(stdin).stdout(stdout).stderr(slave); let effective_workdir = spec.workdir.as_deref().or(self.base_workdir.as_deref()); #[cfg(target_os = "linux")] @@ -385,6 +401,11 @@ impl LocalBoundaryExec { let managed_child = child_registry.register(pid); #[cfg(target_os = "linux")] drop(child_registry); + let audit = + audit_program.map(|program| crate::process_audit::ProcessAudit::capture(&program, pid)); + if let Some(audit) = &audit { + ocsf_emit!(audit.launch_event()); + } let terminal: Arc = Arc::new(LocalTerminal { master }); let process = Arc::new(LocalExecProcess::new( child, @@ -392,6 +413,7 @@ impl LocalBoundaryExec { self.runtime.clone(), process_terminal, signal_lock, + audit, #[cfg(target_os = "linux")] managed_child, )); @@ -561,6 +583,7 @@ impl BoundaryTerminal for LocalTerminal { struct LocalExecProcess { pid: u32, + process_identity: Option, result: Arc>>>, exited: Arc, runtime: Arc, @@ -575,8 +598,12 @@ impl LocalExecProcess { runtime: Arc, terminal: Arc, signal_lock: Arc>, + audit: Option, #[cfg(target_os = "linux")] managed_child: Option, ) -> Self { + let process_identity = audit + .as_ref() + .map(crate::process_audit::ProcessAudit::identity); let result = Arc::new(std::sync::Mutex::new(None)); let exited = Arc::new(tokio::sync::Notify::new()); let result_for_wait = result.clone(); @@ -630,6 +657,11 @@ impl LocalExecProcess { } BoundaryExitStatus::Exited(status.code().unwrap_or(1)) }); + if let Some(audit) = &audit + && let Ok(status) = waited.as_ref() + { + ocsf_emit!(audit.terminate_event(*status)); + } runtime_for_wait.unregister_process_group(pid, ®istration_terminal); if let Ok(mut slot) = result_for_wait.lock() { *slot = Some(waited); @@ -638,6 +670,7 @@ impl LocalExecProcess { }); Self { pid, + process_identity, result, exited, runtime, @@ -662,6 +695,11 @@ impl LocalExecProcess { #[async_trait] impl BoundaryProcess for LocalExecProcess { + fn process_identity( + &self, + ) -> Option { + self.process_identity.clone() + } async fn wait(&self) -> Result { loop { let notified = self.exited.notified(); @@ -737,6 +775,7 @@ mod tests { HashMap::new(), crate::boundary_io::BoundaryRuntimeState::new(), otlp_relay_active, + false, launcher, ) } diff --git a/crates/openshell-sandbox/src/boundary_io.rs b/crates/openshell-sandbox/src/boundary_io.rs index 7d0e65f7b0..12b1f89c2f 100644 --- a/crates/openshell-sandbox/src/boundary_io.rs +++ b/crates/openshell-sandbox/src/boundary_io.rs @@ -333,6 +333,14 @@ fn read_proc_stat(pid: u32) -> Option { }) } +/// Return a process's namespace parent PID and kernel start tick while it is +/// still owned by its caller. Used to identify audited process instances. +#[cfg(target_os = "linux")] +pub(crate) fn process_parent_and_start(pid: u32) -> Option<(u32, u64)> { + let stat = read_proc_stat(pid)?; + Some((stat.parent, stat.start_time)) +} + /// Whether orphaned descendants are reparented to this sandbox process. #[cfg(target_os = "linux")] fn sandbox_owns_process_tree() -> bool { diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 4eb3f1ec02..28dff9490a 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -2106,6 +2106,7 @@ mod linux { response: Response::ExecStarted { process_id: started.process_id.clone(), pty: started.terminal, + process_identity: started.process_identity, }, }, ) { @@ -2606,6 +2607,7 @@ mod linux { struct StartedExec { process_id: String, terminal: bool, + process_identity: Option, attachment: MainAttachment, } @@ -3495,6 +3497,7 @@ mod linux { return Ok(StartedExec { process_id: process_id.clone(), terminal: handle.terminal.is_some(), + process_identity: handle.process.process_identity(), attachment: acquire_exec_attachment(handle)?, }); } @@ -3575,11 +3578,13 @@ mod linux { status, }; let terminal = handle.terminal.is_some(); + let process_identity = handle.process.process_identity(); let attachment = acquire_exec_attachment(&handle)?; handles.insert(process_id.clone(), handle); Ok(StartedExec { process_id, terminal, + process_identity, attachment, }) } @@ -3739,6 +3744,7 @@ mod linux { kind: ProcessKindWire::Main, terminal: process.main_session().terminal(), status: process.exit_status(), + process_identity: process.process_identity(), retained_output: OutputWindowWire { first_sequence, next_sequence, @@ -3754,6 +3760,7 @@ mod linux { kind: ProcessKindWire::Exec, terminal: handle.terminal.is_some(), status: *lock(&handle.status), + process_identity: handle.process.process_identity(), retained_output: OutputWindowWire { first_sequence, next_sequence, @@ -3900,6 +3907,7 @@ mod linux { process_id: process.process_id(), provider_env_revision: process.provider_credentials.snapshot().revision, provider_env_generation: *lock(&process.provider_environment_generation), + process_identity: process.process_identity(), } } else { guest_error( @@ -3970,6 +3978,7 @@ mod linux { Err(error) => return guest_error(BoundaryErrorKind::Process, error), }; let process_id = process.process_id(); + let process_identity = process.process_identity(); *lock(&self.started_agent) = Some(requested); *state = RuntimeState::Running(process); openshell_ocsf::ocsf_emit!( @@ -3985,6 +3994,7 @@ mod linux { process_id, provider_env_revision, provider_env_generation: 0, + process_identity, } } @@ -4321,6 +4331,7 @@ mod linux { struct ManagedProcess { process_id: String, + process_identity: Option, signaler: AgentSignaler, exit: SharedProcessExit, boundary_exec: Arc, @@ -4396,9 +4407,11 @@ mod linux { ca_file_paths, Some(boundary_runtime.clone()), spec.otlp_relay_active, + spec.process_activity_enabled, )) .map_err(|error| format!("start process supervisor leaf: {error:?}"))?; let signaler = spawned.signaler(); + let process_identity = spawned.process_identity(); let boundary_exec = spawned.boundary_exec(); let loopback_connector = spawned.loopback_connector(); let main_session = spawned.main_session(); @@ -4416,6 +4429,7 @@ mod linux { }); Ok(Self { process_id, + process_identity, signaler, exit, boundary_exec, @@ -4432,6 +4446,12 @@ mod linux { self.process_id.clone() } + fn process_identity( + &self, + ) -> Option { + self.process_identity.clone() + } + fn wait(&self) -> ProcessExit { let (state, changed) = &*self.exit; let mut exit = lock(state); @@ -5409,6 +5429,7 @@ mod linux { timeout_secs: 0, interactive: true, otlp_relay_active: false, + process_activity_enabled: false, }) .expect("resolve scratch command"); @@ -8022,6 +8043,7 @@ mod linux { timeout_secs: 60, interactive: false, otlp_relay_active: false, + process_activity_enabled: true, }; assert!(matches!( @@ -8044,10 +8066,18 @@ mod linux { process_id, provider_env_revision: 0, provider_env_generation: 0, + process_identity: Some(process_identity), } = start() else { panic!("initial start did not succeed"); }; + assert_eq!(process_identity.name, "/bin/sleep"); + assert!( + process_identity + .uid + .as_deref() + .is_some_and(|uid| uid.starts_with(&format!("{}:", process_identity.pid))) + ); let update = RequestEnvelope::new(Request::UpdateProviderEnvironment { provider_files: std::collections::HashMap::new(), @@ -8131,6 +8161,7 @@ mod linux { process_id: process_id.clone(), provider_env_revision: 7, provider_env_generation: 1, + process_identity: Some(process_identity.clone()), } ); @@ -8250,6 +8281,12 @@ mod linux { ) .expect("exec after reconnect"); let exec_id = exec.process_id.clone(); + let exec_identity = exec + .process_identity + .clone() + .expect("exec identity is captured"); + assert_eq!(exec_identity.name, "/bin/sh"); + assert!(exec_identity.uid.is_some()); let mut output = String::new(); let mut cursor = exec.attachment.session.subscribe(); process_runtime.block_on(async { @@ -8282,12 +8319,15 @@ mod linux { }; assert_eq!(snapshot.generation, "generation-reconnect"); assert!(snapshot.processes.iter().any(|process| { - process.process_id == process_id && process.kind == ProcessKindWire::Main + process.process_id == process_id + && process.kind == ProcessKindWire::Main + && process.process_identity.as_ref() == Some(&process_identity) })); assert!(snapshot.processes.iter().any(|process| { process.process_id == exec_id && process.kind == ProcessKindWire::Exec && process.status == Some(ExitStatusWire::Exited(0)) + && process.process_identity.as_ref() == Some(&exec_identity) && process.retained_output.next_sequence > 0 })); assert_eq!(boundary.terminate(&process_id), Response::Terminated); @@ -8464,6 +8504,7 @@ mod linux { timeout_secs: 120, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, None, None, @@ -8950,6 +8991,7 @@ mod linux { timeout_secs: 120, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, None, None, @@ -9557,6 +9599,7 @@ mod linux { timeout_secs: 120, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, None, None, @@ -9694,6 +9737,7 @@ mod linux { timeout_secs: 5, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }; let wire_policy = SandboxPolicyWire::from(policy.clone()); let process = Arc::new( @@ -9773,6 +9817,7 @@ mod linux { process_id: process.process_id(), provider_env_revision: 0, provider_env_generation: 0, + process_identity: None, } ); @@ -9876,6 +9921,7 @@ mod linux { process_id: process.process_id(), provider_env_revision: 1, provider_env_generation: 2, + process_identity: None, }, "a replacement control must resume from the boundary's current revision" ); diff --git a/crates/openshell-sandbox/src/delegated.rs b/crates/openshell-sandbox/src/delegated.rs index 7b3ec12b16..61333436d7 100644 --- a/crates/openshell-sandbox/src/delegated.rs +++ b/crates/openshell-sandbox/src/delegated.rs @@ -42,6 +42,7 @@ pub async fn spawn_workload( ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, boundary_runtime: Option>, otlp_relay_active: bool, + process_activity_enabled: bool, ) -> Result { // Driver-selected workspaces are the sandbox identity's home. This keeps // canonical and later exec processes consistent for image WorkingDir and @@ -85,6 +86,7 @@ pub async fn spawn_workload( user_environment, boundary_runtime.clone(), otlp_relay_active, + process_activity_enabled, launcher.clone(), )); @@ -129,19 +131,25 @@ pub async fn spawn_workload( .register_process_group(handle.pid(), terminal.clone(), signal_lock.clone()) .map_err(|error| miette::miette!(error.to_string()))?; - ocsf_emit!( - ProcessActivityBuilder::new(ocsf_ctx()) - .activity(ActivityId::Open) - .action(ActionId::Allowed) - .disposition(DispositionId::Allowed) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .launch_type(LaunchTypeId::Spawn) - .process(OcsfProcess::new(program, i64::from(handle.pid()))) - .actor_process(crate::process::ocsf_launcher_process()) - .message(format!("Process started: pid={}", handle.pid())) - .build() - ); + let audit = process_activity_enabled + .then(|| crate::process_audit::ProcessAudit::capture(program, handle.pid())); + if let Some(audit) = &audit { + ocsf_emit!(audit.launch_event()); + } else { + ocsf_emit!( + ProcessActivityBuilder::new(ocsf_ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .launch_type(LaunchTypeId::Spawn) + .process(OcsfProcess::new(program, i64::from(handle.pid()))) + .actor_process(crate::process::ocsf_launcher_process()) + .message(format!("Process started: pid={}", handle.pid())) + .build() + ); + } Ok(SpawnedAgent { handle, @@ -152,6 +160,7 @@ pub async fn spawn_workload( boundary_exec, loopback_connector, boundary_runtime, + audit, }) } @@ -165,9 +174,17 @@ pub struct SpawnedAgent { boundary_exec: Arc, loopback_connector: Arc, boundary_runtime: Arc, + audit: Option, } impl SpawnedAgent { + pub(crate) fn process_identity( + &self, + ) -> Option { + self.audit + .as_ref() + .map(crate::process_audit::ProcessAudit::identity) + } #[must_use] pub fn signaler(&self) -> AgentSignaler { AgentSignaler { @@ -213,6 +230,17 @@ impl SpawnedAgent { }; self.boundary_runtime .unregister_process_group(self.handle.pid(), &self.terminal); + if let Some(audit) = &self.audit { + let exit = status.signal().map_or_else( + || { + openshell_isolation_interface::contract::BoundaryExitStatus::Exited( + status.code(), + ) + }, + openshell_isolation_interface::contract::BoundaryExitStatus::Signaled, + ); + ocsf_emit!(audit.terminate_event(exit)); + } let _ = self.main_session.finish(status.code(), false).await; self.main_session.mark_terminal_reported(); Ok(status) diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 3a7fd8beb4..10d0681cae 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -27,6 +27,8 @@ pub mod perf; #[cfg(target_os = "linux")] pub mod process; #[cfg(target_os = "linux")] +mod process_audit; +#[cfg(target_os = "linux")] mod provider_files; #[cfg(target_os = "linux")] mod pty; diff --git a/crates/openshell-sandbox/src/native_api.rs b/crates/openshell-sandbox/src/native_api.rs index 3d9adff950..28318450a8 100644 --- a/crates/openshell-sandbox/src/native_api.rs +++ b/crates/openshell-sandbox/src/native_api.rs @@ -12,6 +12,12 @@ use std::sync::Arc; #[async_trait] pub trait BoundaryProcess: Send + Sync { + /// Identity captured at spawn time, when this process is opted in to audit. + fn process_identity( + &self, + ) -> Option { + None + } /// Attach to the admitted process's retained standard I/O. The boundary /// remains the process owner and may permit only one control attachment. async fn attach(&self) -> Result { diff --git a/crates/openshell-sandbox/src/process_audit.rs b/crates/openshell-sandbox/src/process_audit.rs new file mode 100644 index 0000000000..fd2e8856a1 --- /dev/null +++ b/crates/openshell-sandbox/src/process_audit.rs @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF lifecycle records for processes directly started by this boundary. + +use std::sync::OnceLock; + +use openshell_isolation_interface::contract::BoundaryExitStatus; +use openshell_ocsf::{ + ActionId, ActivityId, DispositionId, LaunchTypeId, OcsfEvent, Process, ProcessActivityBuilder, + SeverityId, StatusId, +}; + +use crate::boundary_io::process_parent_and_start; +use openshell_sandbox_backend::boundary_protocol::ProcessIdentityWire; + +/// A captured process instance. Keep this across the wait: `/proc/` may +/// be gone or the PID may have been reused by the time the exit is emitted. +#[derive(Clone)] +pub struct ProcessAudit { + process: Process, +} + +impl ProcessAudit { + pub fn capture(program: &str, pid: u32) -> Self { + let mut process = Process::new(program, i64::from(pid)); + if let Some((parent_pid, start_ticks)) = process_parent_and_start(pid) { + process = with_identity(process, pid, start_ticks); + let parent_name = if parent_pid == std::process::id() { + "openshell-sandbox" + } else { + "unknown" + }; + let mut parent = Process::new(parent_name, i64::from(parent_pid)); + if let Some((_, parent_start_ticks)) = process_parent_and_start(parent_pid) { + parent = with_identity(parent, parent_pid, parent_start_ticks); + } + process = process.with_parent(parent); + } + Self { process } + } + + pub fn identity(&self) -> ProcessIdentityWire { + let parent = self.process.parent_process.as_deref(); + ProcessIdentityWire { + name: self.process.name.clone(), + pid: u32::try_from(self.process.pid).expect("captured PID fits u32"), + uid: self.process.uid.clone(), + created_time_ms: self.process.created_time, + parent_pid: parent.and_then(|process| u32::try_from(process.pid).ok()), + parent_uid: parent.and_then(|process| process.uid.clone()), + parent_created_time_ms: parent.and_then(|process| process.created_time), + } + } + + pub fn launch_event(&self) -> OcsfEvent { + ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .launch_type(LaunchTypeId::Spawn) + .process(self.process.clone()) + .actor_process(crate::process::ocsf_launcher_process()) + .message(format!("Process started: pid={}", self.process.pid)) + .build() + } + + pub fn terminate_event(&self, status: BoundaryExitStatus) -> OcsfEvent { + let mut builder = ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Close) + .severity(SeverityId::Informational) + .process(self.process.clone()) + .actor_process(crate::process::ocsf_launcher_process()); + if let BoundaryExitStatus::Exited(code) = status { + builder = builder.exit_code(code); + } + let mut event = builder.build(); + if let BoundaryExitStatus::Signaled(signal) = status + && let OcsfEvent::ProcessActivity(process_event) = &mut event + { + process_event.base.unmapped = Some(serde_json::json!({"signal": signal})); + } + event + } +} + +fn with_identity(mut process: Process, pid: u32, start_ticks: u64) -> Process { + // The PID is namespace local; the start tick distinguishes reuse in one + // sandbox lifetime even when two starts round to the same millisecond. + process = process.with_uid(format!("{pid}:{start_ticks}")); + if let Some(time_ms) = process_created_time_ms(start_ticks) { + process = process.with_created_time(time_ms); + } + process +} + +#[allow(unsafe_code)] +fn process_created_time_ms(start_ticks: u64) -> Option { + static CLOCK: OnceLock> = OnceLock::new(); + let (boot_seconds, ticks_per_second) = (*CLOCK.get_or_init(|| { + let stat = std::fs::read_to_string("/proc/stat").ok()?; + let boot_seconds = stat + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .parse::() + .ok()?; + // SAFETY: sysconf reads a process-wide kernel parameter and does not + // access caller-owned memory. + let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + let ticks_per_second = u64::try_from(ticks_per_second).ok().filter(|hz| *hz > 0)?; + Some((boot_seconds, ticks_per_second)) + }))?; + let milliseconds = boot_seconds + .checked_mul(1_000)? + .checked_add(start_ticks.checked_mul(1_000)? / ticks_per_second)?; + i64::try_from(milliseconds).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn captures_a_stable_process_instance_without_arguments() { + let pid = std::process::id(); + let audit = ProcessAudit::capture("openshell-sandbox", pid); + let event = audit.launch_event().to_json().unwrap(); + let (_, ticks) = process_parent_and_start(pid).unwrap(); + assert_eq!(event["process"]["pid"], pid); + assert_eq!(event["process"]["uid"], format!("{pid}:{ticks}")); + assert!(event["process"]["created_time"].is_i64()); + assert!(event["process"]["cmd_line"].is_null()); + } + + #[test] + fn normal_and_signal_exits_keep_the_launch_identity() { + let audit = ProcessAudit::capture("test", std::process::id()); + let launch = audit.launch_event().to_json().unwrap(); + let normal = audit + .terminate_event(BoundaryExitStatus::Exited(7)) + .to_json() + .unwrap(); + assert_eq!(normal["process"], launch["process"]); + assert_eq!(normal["exit_code"], 7); + + let signal = audit + .terminate_event(BoundaryExitStatus::Signaled(9)) + .to_json() + .unwrap(); + assert_eq!(signal["process"], launch["process"]); + assert_eq!(signal["unmapped"]["signal"], 9); + assert!(signal.get("exit_code").is_none()); + } + + #[test] + fn a_short_lived_child_keeps_its_identity_after_reaping() { + let mut child = std::process::Command::new("/bin/true") + .spawn() + .expect("spawn child"); + let pid = child.id(); + let audit = ProcessAudit::capture("/bin/true", pid); + let launch = audit.launch_event().to_json().unwrap(); + let status = child.wait().expect("reap child"); + assert!(process_parent_and_start(pid).is_none()); + + let exit = audit + .terminate_event(BoundaryExitStatus::Exited(status.code().unwrap())) + .to_json() + .unwrap(); + assert_eq!(launch["process"], exit["process"]); + assert_eq!( + launch["process"]["parent_process"]["pid"], + std::process::id() + ); + assert_eq!(launch["process"]["uid"], exit["process"]["uid"]); + assert_eq!(exit["exit_code"], 0); + } +} diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index bdf4035c61..9d31982d01 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -223,6 +223,7 @@ fn agent() -> AgentSpec { timeout_secs: 15, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, } } @@ -314,6 +315,7 @@ impl PreparedBackend for TestLaunch { kind: ProcessKindWire::Main, terminal: false, status: None, + process_identity: None, retained_output: OutputWindowWire { first_sequence: 0, next_sequence: 0, @@ -380,6 +382,7 @@ impl PreparedBackend for TestLaunch { process_id: "main".into(), provider_env_revision, provider_env_generation: 1, + process_identity: None, } } Request::Wait { .. } => Response::Exited { diff --git a/crates/openshell-supervisor/src/config_runtime.rs b/crates/openshell-supervisor/src/config_runtime.rs index 8ced34ffec..e2512c2cd8 100644 --- a/crates/openshell-supervisor/src/config_runtime.rs +++ b/crates/openshell-supervisor/src/config_runtime.rs @@ -1526,6 +1526,16 @@ pub fn agent_proposals_enabled_from_settings( .unwrap_or(false) } +pub fn process_activity_enabled_from_settings( + settings: &std::collections::HashMap, +) -> bool { + extract_bool_setting( + settings, + openshell_core::settings::PROCESS_ACTIVITY_ENABLED_KEY, + ) + .unwrap_or(false) +} + fn apply_agent_proposals_enabled( agent_proposals: &AgentProposals, enabled: bool, @@ -1754,6 +1764,17 @@ mod tests { assert!(!agent_proposals_enabled_from_settings(&settings)); } + #[test] + fn process_activity_setting_is_opt_in() { + let mut settings = std::collections::HashMap::new(); + assert!(!process_activity_enabled_from_settings(&settings)); + settings.insert( + openshell_core::settings::PROCESS_ACTIVITY_ENABLED_KEY.to_string(), + effective_bool(true), + ); + assert!(process_activity_enabled_from_settings(&settings)); + } + // ---- Policy disk discovery tests ---- #[derive(Clone)] diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 8b1035beca..8aef39e1f9 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -28,7 +28,10 @@ mod provider_readiness; pub mod sandbox_instance_host; pub mod shared_host; -use config_runtime::{agent_proposals_enabled_from_settings, run_policy_poll_loop}; +use config_runtime::{ + agent_proposals_enabled_from_settings, process_activity_enabled_from_settings, + run_policy_poll_loop, +}; use miette::{IntoDiagnostic, Result, WrapErr}; use std::future::Future; use std::io::Write as _; @@ -692,7 +695,7 @@ pub async fn run_network_proxy( } let extension_credentials = openshell_extension_core::ExtensionCredentialStore::new(); - let (mut policy, opa_engine, _, _, _, initial_agent_proposals_enabled, _, _) = load_policy( + let (mut policy, opa_engine, _, _, _, initial_agent_proposals_enabled, _, _, _) = load_policy( None, None, None, @@ -1183,6 +1186,7 @@ pub async fn run_sandbox_instance( middleware_registry_status, loaded_policy_origin, initial_agent_proposals_enabled, + initial_process_activity_enabled, initial_extension_authentication_enabled, captured_provider_environment, ) = load_policy_with_gateway( @@ -1320,6 +1324,7 @@ pub async fn run_sandbox_instance( timeout_secs, interactive, otlp_relay_active: otlp_relay.is_some(), + process_activity_enabled: initial_process_activity_enabled, }, ) .instrument(tracing::info_span!(parent: &startup, "supervisor.boundary.attach")) @@ -2749,6 +2754,7 @@ async fn load_policy( LoadedPolicyOrigin, bool, bool, + bool, Option, )> { load_policy_with_gateway( @@ -2791,6 +2797,7 @@ async fn load_policy_with_gateway( LoadedPolicyOrigin, bool, bool, + bool, Option, )> { use openshell_core::proto::ConfigurationAdmissionState; @@ -2857,6 +2864,7 @@ async fn load_policy_with_gateway( LoadedPolicyOrigin::LocalOverride, false, false, + false, None, )); } @@ -3227,6 +3235,7 @@ async fn load_policy_with_gateway( has_last_valid_policy, }, agent_proposals_enabled_from_settings(&snapshot.settings), + process_activity_enabled_from_settings(&snapshot.settings), snapshot.extension_authentication_enabled, Some(CapturedProviderEnvironment::new( captured_provider_credentials, @@ -5558,7 +5567,7 @@ network_policies: .unwrap() .expect("repair returns one launch bundle"); assert!( - bundle.7.is_some(), + bundle.8.is_some(), "launch bundle retains matching provider state" ); } @@ -6786,6 +6795,7 @@ network_policies: registry, origin, proposals, + process_activity_enabled, extension_authentication_enabled, provider_credentials, ) = startup().await.expect("load valid local policy"); @@ -6793,7 +6803,7 @@ network_policies: assert!(proto.is_none()); assert!(matches!(origin, LoadedPolicyOrigin::LocalOverride)); assert!(matches!(registry, MiddlewareRegistryStatus::Synchronized)); - assert!(!proposals && !extension_authentication_enabled); + assert!(!proposals && !extension_authentication_enabled && !process_activity_enabled); let engine = engine.expect("local startup installs OPA"); assert!(engine.binary_identity_required()); // Installing the built-in registry creates the first active generation. diff --git a/docs/observability/accessing-logs.mdx b/docs/observability/accessing-logs.mdx index 9e19c637bf..eb0506a888 100644 --- a/docs/observability/accessing-logs.mdx +++ b/docs/observability/accessing-logs.mdx @@ -43,7 +43,14 @@ docker logs podman logs ``` -The sandbox runtime also emits OCSF shorthand events, including process-launch and Landlock filesystem events, into these container logs when its diagnostic log filter permits them. These runtime-originated events are not pushed to the gateway log stream or included in the supervisor's JSONL file. Collect workload container logs separately from supervisor logs to retain both sources. +The sandbox runtime also emits OCSF shorthand events, including process-launch +and Landlock filesystem events, into these container logs when its diagnostic +log filter permits them. Runtime-originated events are not pushed to the gateway +log stream or included in the supervisor's JSONL file. When +`process_activity_enabled` is on, the runtime sends directly started process +identities to the supervisor, which emits separate launch and exit records into +those supervisor paths. Collect workload container logs separately from +supervisor logs to retain both sources. A main process started with a TTY writes only to its attachment; use `openshell sandbox connect` to view that output. Output from `sandbox exec` and SSH sessions does not reach the container log, and `openshell logs` does not include main process output. diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index d7f068dca9..3c8c04ba85 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -198,6 +198,41 @@ A process launched inside the sandbox: OCSF PROC:LAUNCH [INFO] sleep(49) ``` +### Boundary process events + +To record launch and exit events for the entrypoint and processes started by +`sandbox exec` or SSH, enable the setting before starting a sandbox: + +```shell +openshell settings set --global --key process_activity_enabled --value true +``` + +The setting defaults to `false` and is captured at sandbox startup. When it is +off, the existing entrypoint launch event remains. When it is on, the sandbox +runtime captures each directly started process and sends its identity to the +supervisor. The supervisor emits one launch and one termination event to the +gateway stream and, when enabled, its OCSF JSONL output. The runtime also +emits these events to its own container log. This covers piped and PTY sessions. +The events never include argv or command lines. Each source emits at most two +additional records per process the runtime directly starts. + +```text +OCSF PROC:LAUNCH [INFO] /bin/sh(57) [ppid:1] [start:1793620000120] [process_uid:57:123456] [parent_uid:1:34] +OCSF PROC:TERMINATE [INFO] /bin/sh(57) [ppid:1] [start:1793620000120] [process_uid:57:123456] [parent_uid:1:34] [exit:0] +``` + +`ppid` is the PID namespace parent. `start` is the process start time in Unix +milliseconds. `process_uid` and `parent_uid` identify process instances as +`pid:start_ticks`, where `start_ticks` is field 22 of `/proc//stat`. +Signal exits carry `[signal:N]` in place of `[exit:N]`. Forked descendants and +an executable that replaces itself are outside this setting's current event +coverage. The log stream can drop records under pressure, so these events do +not prove that a process tree is complete. + +Use `openshell logs --source sandbox` to read the supervisor records +through the gateway. See [Accessing Logs](/observability/accessing-logs) for +stream retention and loss behavior. + A policy reload after a settings change: ```text diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index 91fb882f3c..096d32ea18 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -205,6 +205,7 @@ message Started { string process_id = 1; uint64 provider_env_revision = 2; uint64 provider_env_generation = 3; + ProcessIdentity process_identity = 4; } message ProviderEnvironmentUpdated { uint64 revision = 1; @@ -223,6 +224,7 @@ message BoundaryTerminated {} message ExecStarted { string process_id = 1; bool pty = 2; + ProcessIdentity process_identity = 3; } message Resized {} message LoopbackConnected {} @@ -275,6 +277,8 @@ message AgentSpec { // point workload SDKs at the relay by default. False keeps the workload // environment unchanged. bool otlp_relay_active = 6; + // Opt in to launch and exit events for boundary-started workload processes. + bool process_activity_enabled = 7; } message ExecSpec { string program = 1; @@ -360,6 +364,17 @@ message ProcessSnapshot { bool terminal = 3; ExitStatus status = 4; OutputWindow retained_output = 5; + ProcessIdentity process_identity = 6; +} +// Runtime-captured identity; no argv, environment, or full command line. +message ProcessIdentity { + string name = 1; + uint32 pid = 2; + optional string uid = 3; + optional int64 created_time_ms = 4; + optional uint32 parent_pid = 5; + optional string parent_uid = 6; + optional int64 parent_created_time_ms = 7; } enum ProcessKind { PROCESS_KIND_UNSPECIFIED = 0; diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 351d7e3b84..266326d9f5 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -979,6 +979,8 @@ openshell settings set --global --key ocsf_json_enabled --value true Enabling `ocsf_json_enabled` adds compact `OCSF-JSON` records to the supervisor's stderr, alongside shorthand, and to its JSONL file when file storage is available. Collect these records through the supervisor's platform logs (`docker logs`, `podman logs`, or `kubectl logs`); `openshell logs` still serves shorthand. Match the `OCSF-JSON` marker after the timestamp and parse the remainder as JSON. See the published [OCSF JSON export guide](https://docs.nvidia.com/openshell/latest/observability/ocsf-json-export) for delivery and retention limits. +To opt in to entrypoint and exec or SSH process lifecycle events for newly started sandboxes, set `process_activity_enabled` to `true` globally before creating them. The setting is captured at startup. See [Sandbox Logging](https://docs.nvidia.com/openshell/latest/observability/logging) for the event shape and coverage. + Global mutations prompt for confirmation. Use `--yes` only in reviewed automation. `policy_validation_failure_mode` is gateway startup configuration, not a From 155c68ac1468719b318e34589cd9b6fd009658e1 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Narajala Date: Mon, 2 Nov 2026 20:11:02 +0000 Subject: [PATCH 2/2] fix(protocol): encode process start times as timestamps Signed-off-by: Vineeth Sai Narajala --- .../openshell-sandbox-backend/src/protobuf.rs | 55 +++++++++++++++++-- proto/sandbox_protocol.proto | 4 +- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/openshell-sandbox-backend/src/protobuf.rs b/crates/openshell-sandbox-backend/src/protobuf.rs index 797fa9fb7e..7d3d70a7b9 100644 --- a/crates/openshell-sandbox-backend/src/protobuf.rs +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -96,10 +96,13 @@ impl WireMessage for w::ProcessIdentityWire { name: self.name.clone(), pid: self.pid, uid: self.uid.clone(), - created_time_ms: self.created_time_ms, + created_time: self.created_time_ms.map(timestamp_i64_ms).transpose()?, parent_pid: self.parent_pid, parent_uid: self.parent_uid.clone(), - parent_created_time_ms: self.parent_created_time_ms, + parent_created_time: self + .parent_created_time_ms + .map(timestamp_i64_ms) + .transpose()?, }) } @@ -108,10 +111,10 @@ impl WireMessage for w::ProcessIdentityWire { name: value.name, pid: value.pid, uid: value.uid, - created_time_ms: value.created_time_ms, + created_time_ms: value.created_time.map(millis_i64).transpose()?, parent_pid: value.parent_pid, parent_uid: value.parent_uid, - parent_created_time_ms: value.parent_created_time_ms, + parent_created_time_ms: value.parent_created_time.map(millis_i64).transpose()?, }) } } @@ -489,6 +492,14 @@ fn millis(v: prost_types::Timestamp) -> Result { Ok(u64::try_from(v.seconds).map_err(invalid)? * 1000 + u64::try_from(v.nanos).map_err(invalid)? / 1_000_000) } +fn timestamp_i64_ms(value: i64) -> Result { + timestamp(u64::try_from(value).map_err(invalid)?) +} + +fn millis_i64(value: prost_types::Timestamp) -> Result { + i64::try_from(millis(value)?).map_err(invalid) +} + impl WireMessage for w::RequestEnvelope { type Proto = p::RequestEnvelope; fn to_proto(&self) -> Result { @@ -879,6 +890,42 @@ where mod tests { use super::*; + #[test] + fn process_identity_uses_well_known_timestamps() { + let identity = w::ProcessIdentityWire { + name: "/bin/true".to_string(), + pid: 42, + uid: None, + created_time_ms: Some(1_793_620_000_123), + parent_pid: Some(1), + parent_uid: None, + parent_created_time_ms: Some(1_793_619_999_456), + }; + let encoded = identity.to_proto().unwrap(); + assert_eq!( + encoded.created_time, + Some(prost_types::Timestamp { + seconds: 1_793_620_000, + nanos: 123_000_000, + }) + ); + assert_eq!( + encoded.parent_created_time, + Some(prost_types::Timestamp { + seconds: 1_793_619_999, + nanos: 456_000_000, + }) + ); + assert_eq!( + w::ProcessIdentityWire::from_proto(encoded.clone()).unwrap(), + identity + ); + + let mut invalid_time = encoded; + invalid_time.created_time.as_mut().unwrap().nanos = 123_000_001; + assert!(w::ProcessIdentityWire::from_proto(invalid_time).is_err()); + } + #[test] fn process_identity_survives_start_exec_and_legacy_responses() { let identity = w::ProcessIdentityWire { diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index 096d32ea18..0fce2fa30b 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -371,10 +371,10 @@ message ProcessIdentity { string name = 1; uint32 pid = 2; optional string uid = 3; - optional int64 created_time_ms = 4; + google.protobuf.Timestamp created_time = 4; optional uint32 parent_pid = 5; optional string parent_uid = 6; - optional int64 parent_created_time_ms = 7; + google.protobuf.Timestamp parent_created_time = 7; } enum ProcessKind { PROCESS_KIND_UNSPECIFIED = 0;