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
8 changes: 8 additions & 0 deletions crates/openshell-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
kind: SettingValueKind::Bool,
allowed_string_values: None,
},
// Target OCSF schema version for JSONL downgrade. When set (e.g. "1.1"
// or "1.3"), the JSONL layer strips fields and profiles that don't exist
// in the target version. Empty or unset means no downgrade.
RegisteredSetting {
key: "ocsf_schema_version",
kind: SettingValueKind::String,
allowed_string_values: None,
},
// Sandbox-level opt-in for the agent-driven policy proposal surface.
// See AGENT_POLICY_PROPOSALS_ENABLED_KEY for details. Defaults to false.
RegisteredSetting {
Expand Down
183 changes: 183 additions & 0 deletions crates/openshell-ocsf/src/format/downgrade.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! OCSF schema version downgrade filter.
//!
//! Transforms serialized OCSF JSON events to conform to older schema versions
//! by stripping fields and profiles that don't exist in the target version.

use serde_json::Value;

/// Fields to strip when downgrading to v1.3.0 or earlier.
const STRIP_FOR_V1_3: &[&str] = &["ai_model", "container", "observation_point_id"];

/// Profile names to remove from `metadata.profiles` when downgrading to v1.3.0 or earlier.
const STRIP_PROFILES_V1_3: &[&str] = &["ai_operation", "container"];

/// Downgrade a serialized OCSF event to the target schema version.
///
/// Modifies the JSON in place: strips fields that don't exist in the target
/// version, removes unknown profile names from `metadata.profiles`, and
/// rewrites `metadata.version` to match.
///
/// Returns `true` if the event was modified, `false` if no changes were needed
/// (target is current version or newer).
pub fn downgrade_event(event: &mut Value, target_version: &str) -> bool {
let target = parse_version(target_version);
let v1_3 = (1, 3, 0);

if target >= parse_version(crate::OCSF_VERSION) {
return false;
}

let Some(obj) = event.as_object_mut() else {
return false;
};

let mut modified = false;

if target <= v1_3 {
for field in STRIP_FOR_V1_3 {
if obj.remove(*field).is_some() {
modified = true;
}
}

if let Some(profiles) = obj
.get_mut("metadata")
.and_then(Value::as_object_mut)
.and_then(|m| m.get_mut("profiles"))
.and_then(Value::as_array_mut)
{
let before = profiles.len();
profiles.retain(|p| !p.as_str().is_some_and(|s| STRIP_PROFILES_V1_3.contains(&s)));
if profiles.len() != before {
modified = true;
}
}
}

if modified && let Some(metadata) = obj.get_mut("metadata").and_then(Value::as_object_mut) {
metadata.insert(
"version".to_string(),
Value::String(target_version.to_string()),
);
}

modified
}

fn parse_version(v: &str) -> (u32, u32, u32) {
let parts: Vec<u32> = v.split('.').filter_map(|s| s.parse().ok()).collect();
(
parts.first().copied().unwrap_or(0),
parts.get(1).copied().unwrap_or(0),
parts.get(2).copied().unwrap_or(0),
)
}

#[cfg(test)]
mod tests {
use super::*;

fn test_event() -> Value {
serde_json::json!({
"class_uid": 4002,
"class_name": "HTTP Activity",
"time": 1_234_567_890,
"severity_id": 1,
"metadata": {
"version": "1.7.0",
"profiles": ["security_control", "network_proxy", "container", "host"]
},
"device": {"hostname": "sandbox-1"},
"container": {"name": "test-sandbox"},
"observation_point_id": 2,
"unmapped": {"key": "value"}
})
}

#[test]
fn test_downgrade_to_v1_3_strips_fields() {
let mut event = test_event();
let modified = downgrade_event(&mut event, "1.3.0");

assert!(modified);
assert!(event.get("container").is_none());
assert!(event.get("observation_point_id").is_none());
assert!(event.get("device").is_some());
assert!(event.get("unmapped").is_some());
}

#[test]
fn test_downgrade_to_v1_1_strips_fields() {
let mut event = test_event();
let modified = downgrade_event(&mut event, "1.1.0");

assert!(modified);
assert!(event.get("container").is_none());
assert!(event.get("observation_point_id").is_none());
}

#[test]
fn test_downgrade_strips_profiles() {
let mut event = test_event();
downgrade_event(&mut event, "1.3.0");

let profiles = event["metadata"]["profiles"].as_array().unwrap();
assert!(!profiles.iter().any(|p| p == "container"));
assert!(profiles.iter().any(|p| p == "security_control"));
assert!(profiles.iter().any(|p| p == "host"));
}

#[test]
fn test_downgrade_rewrites_version() {
let mut event = test_event();
downgrade_event(&mut event, "1.1.0");

assert_eq!(event["metadata"]["version"], "1.1.0");
}

#[test]
fn test_no_downgrade_for_current_version() {
let mut event = test_event();
let modified = downgrade_event(&mut event, "1.7.0");

assert!(!modified);
assert_eq!(event["metadata"]["version"], "1.7.0");
}

#[test]
fn test_no_downgrade_for_newer_version() {
let mut event = test_event();
let modified = downgrade_event(&mut event, "1.9.0");

assert!(!modified);
}

#[test]
fn test_downgrade_strips_ai_model_when_present() {
let mut event = serde_json::json!({
"class_uid": 6003,
"metadata": {
"version": "1.8.0",
"profiles": ["container", "host", "ai_operation"]
},
"ai_model": {"name": "claude-3-haiku", "ai_provider": "anthropic"},
"unmapped": {"latency_ms": 701}
});
let modified = downgrade_event(&mut event, "1.3.0");

assert!(modified);
assert!(event.get("ai_model").is_none());
assert!(
!event["metadata"]["profiles"]
.as_array()
.unwrap()
.iter()
.any(|p| p == "ai_operation")
);
assert_eq!(event["metadata"]["version"], "1.3.0");
assert!(event.get("unmapped").is_some());
}
}
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@

//! OCSF event formatters: shorthand (human-readable) and JSONL.

pub mod downgrade;
pub mod jsonl;
pub mod shorthand;
40 changes: 39 additions & 1 deletion crates/openshell-ocsf/src/tracing_layers/jsonl_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use tracing::Subscriber;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;

use crate::format::downgrade::downgrade_event;
use crate::tracing_layers::event_bridge::{OCSF_TARGET, clone_current_event};

/// A tracing `Layer` that intercepts OCSF events and writes JSONL output.
Expand All @@ -23,9 +24,15 @@ use crate::tracing_layers::event_bridge::{OCSF_TARGET, clone_current_event};
/// `false`, the layer short-circuits without writing. This allows the sandbox
/// to hot-toggle OCSF JSONL output at runtime via the `ocsf_json_enabled`
/// setting without rebuilding the subscriber.
///
/// An optional target schema version can be set via
/// [`with_target_version`](Self::with_target_version). When set, events are
/// downgraded to the target version before writing (stripping fields and
/// profiles that don't exist in older schema versions).
pub struct OcsfJsonlLayer<W: Write + Send + 'static> {
writer: Mutex<W>,
enabled: Option<Arc<AtomicBool>>,
target_version: Option<Arc<Mutex<String>>>,
}

impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
Expand All @@ -35,6 +42,7 @@ impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
Self {
writer: Mutex::new(writer),
enabled: None,
target_version: None,
}
}

Expand All @@ -47,6 +55,16 @@ impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
self.enabled = Some(flag);
self
}

/// Attach a shared target schema version for downgrade filtering.
///
/// When set, events are downgraded to the target version before writing.
/// The version can be changed at runtime via the shared mutex.
#[must_use]
pub fn with_target_version(mut self, version: Arc<Mutex<String>>) -> Self {
self.target_version = Some(version);
self
}
}

impl<S, W> Layer<S> for OcsfJsonlLayer<W>
Expand All @@ -67,9 +85,29 @@ where
}

if let Some(ocsf_event) = clone_current_event()
&& let Ok(line) = ocsf_event.to_json_line()
&& let Ok(mut w) = self.writer.lock()
{
let line = if let Some(ref target) = self.target_version
&& let Ok(version) = target.lock()
&& !version.is_empty()
{
let Ok(mut json) = serde_json::to_value(&ocsf_event) else {
return;
};
downgrade_event(&mut json, &version);
match serde_json::to_string(&json) {
Ok(mut s) => {
s.push('\n');
s
}
Err(_) => return,
}
} else {
match ocsf_event.to_json_line() {
Ok(l) => l,
Err(_) => return,
}
};
let _ = w.write_all(line.as_bytes());
}
}
Expand Down
39 changes: 39 additions & 0 deletions crates/openshell-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ pub async fn run_sandbox(
_health_port: u16,
inference_routes: Option<String>,
ocsf_enabled: Arc<AtomicBool>,
ocsf_schema_version: Arc<std::sync::Mutex<String>>,
network_enabled: bool,
process_enabled: bool,
upstream_proxy_args: openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs,
Expand Down Expand Up @@ -608,6 +609,7 @@ pub async fn run_sandbox(
let poll_endpoint = endpoint.to_string();
let poll_engine = engine.clone();
let poll_ocsf_enabled = ocsf_enabled.clone();
let poll_ocsf_schema_version = ocsf_schema_version.clone();
let poll_pid = entrypoint_pid.clone();
let poll_provider_credentials = provider_credentials.clone();
let poll_policy_local = networking.as_ref().map(|n| n.policy_local_ctx.clone());
Expand All @@ -623,6 +625,7 @@ pub async fn run_sandbox(
entrypoint_pid: poll_pid,
interval_secs: poll_interval_secs,
ocsf_enabled: poll_ocsf_enabled,
ocsf_schema_version: poll_ocsf_schema_version,
provider_credentials: poll_provider_credentials,
policy_local_ctx: poll_policy_local,
agent_proposals: agent_proposals.clone(),
Expand Down Expand Up @@ -2767,6 +2770,7 @@ struct PolicyPollLoopContext {
entrypoint_pid: Arc<AtomicU32>,
interval_secs: u64,
ocsf_enabled: Arc<AtomicBool>,
ocsf_schema_version: Arc<std::sync::Mutex<String>>,
provider_credentials: ProviderCredentialState,
policy_local_ctx: Option<Arc<openshell_supervisor_network::policy_local::PolicyLocalContext>>,
agent_proposals: AgentProposals,
Expand Down Expand Up @@ -3098,6 +3102,7 @@ async fn run_policy_poll_loop_with_client<C: PolicyGatewayClient>(
match initial_poll_disposition(&ctx.loaded_policy_origin, &result) {
InitialPollDisposition::Acknowledge(candidate) => {
apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings);
apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings);
apply_agent_proposals_enabled(
&ctx.agent_proposals,
agent_proposals_enabled_from_settings(&result.settings),
Expand All @@ -3123,6 +3128,7 @@ async fn run_policy_poll_loop_with_client<C: PolicyGatewayClient>(
InitialPollDisposition::Reconcile => pending_result = Some(result),
InitialPollDisposition::TrackOnly => {
apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings);
apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings);
apply_agent_proposals_enabled(
&ctx.agent_proposals,
agent_proposals_enabled_from_settings(&result.settings),
Expand Down Expand Up @@ -3532,6 +3538,7 @@ async fn run_policy_poll_loop_with_client<C: PolicyGatewayClient>(

// Apply OCSF JSON toggle from the `ocsf_json_enabled` setting.
apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings);
apply_ocsf_schema_version_setting(&ctx.ocsf_schema_version, &result.settings);

// Apply the agent-proposals feature toggle. On a false→true transition
// we lazily install the skill so a sandbox that started with the flag
Expand Down Expand Up @@ -3586,6 +3593,37 @@ fn extract_bool_setting(
})
}

fn apply_ocsf_schema_version_setting(
version: &std::sync::Mutex<String>,
settings: &std::collections::HashMap<String, openshell_core::proto::EffectiveSetting>,
) {
let new_version = extract_string_setting(settings, "ocsf_schema_version").unwrap_or_default();
if let Ok(mut current) = version.lock()
&& *current != new_version
{
info!(
ocsf_schema_version = %new_version,
"OCSF schema version target changed"
);
*current = new_version;
}
}

fn extract_string_setting(
settings: &std::collections::HashMap<String, openshell_core::proto::EffectiveSetting>,
key: &str,
) -> Option<String> {
use openshell_core::proto::setting_value;
settings
.get(key)
.and_then(|es| es.value.as_ref())
.and_then(|sv| sv.value.as_ref())
.and_then(|v| match v {
setting_value::Value::StringValue(s) => Some(s.clone()),
_ => None,
})
}

fn agent_proposals_enabled_from_settings(
settings: &std::collections::HashMap<String, openshell_core::proto::EffectiveSetting>,
) -> bool {
Expand Down Expand Up @@ -4135,6 +4173,7 @@ filesystem_policy:
entrypoint_pid: Arc::new(AtomicU32::new(0)),
interval_secs: 0,
ocsf_enabled: Arc::new(AtomicBool::new(false)),
ocsf_schema_version: Arc::new(std::sync::Mutex::new(String::new())),
provider_credentials: ProviderCredentialState::from_child_env_snapshot(
0,
std::collections::HashMap::new(),
Expand Down
Loading
Loading