Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/openshell-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-isolation-interface/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
82 changes: 80 additions & 2 deletions crates/openshell-ocsf/src/format/shorthand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}]"))
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions crates/openshell-ocsf/src/objects/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Process start time in Unix epoch milliseconds.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_time: Option<i64>,

/// Full command line.
#[serde(skip_serializing_if = "Option::is_none")]
pub cmd_line: Option<String>,
Expand All @@ -32,6 +40,8 @@ impl Process {
Self {
name: name.to_string(),
pid,
uid: None,
created_time: None,
cmd_line: None,
parent_process: None,
}
Expand All @@ -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<String>) -> 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 {
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 8 additions & 1 deletion crates/openshell-ocsf/tests/roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-sandbox-backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 10 additions & 0 deletions crates/openshell-sandbox-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions crates/openshell-sandbox-backend/src/boundary_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProcessIdentityWire>,
},
ProviderEnvironmentUpdated {
revision: u64,
Expand All @@ -1022,6 +1024,8 @@ pub enum Response {
ExecStarted {
process_id: String,
pty: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
process_identity: Option<ProcessIdentityWire>,
},
Resized,
LoopbackConnected,
Expand Down Expand Up @@ -1067,6 +1071,21 @@ pub struct ProcessSnapshotWire {
pub terminal: bool,
pub status: Option<ExitStatusWire>,
pub retained_output: OutputWindowWire,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub process_identity: Option<ProcessIdentityWire>,
}

/// 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<String>,
pub created_time_ms: Option<i64>,
pub parent_pid: Option<u32>,
pub parent_uid: Option<String>,
pub parent_created_time_ms: Option<i64>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -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<AgentSpec> for AgentSpecWire {
Expand All @@ -1274,6 +1296,7 @@ impl From<AgentSpec> for AgentSpecWire {
timeout_secs: spec.timeout_secs,
interactive: spec.interactive,
otlp_relay_active: spec.otlp_relay_active,
process_activity_enabled: spec.process_activity_enabled,
}
}
}
Expand Down Expand Up @@ -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!(
Expand All @@ -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]
Expand All @@ -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()),
Expand Down Expand Up @@ -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)),
Expand Down
Loading
Loading