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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ For the field-by-field YAML reference, use
Filesystem and process policy are startup-time controls. Network policy is
dynamic and can be hot-reloaded when the new policy validates successfully.

## Filesystem Baseline

The supervisor enriches filesystem policy at startup with OpenShell runtime
baseline paths required by proxy mode and optional runtime features such as GPU
support. Baseline paths are only added if they exist in the sandbox image, which
prevents a missing baseline path from causing the whole Landlock ruleset to be
skipped under best-effort mode.

`filesystem_policy.runtime_baseline_conflicts` controls how OpenShell resolves
conflicts between runtime baseline requirements and the effective filesystem
policy. The current conflict policy is `read_only_to_read_write`, where the
default is equivalent to `mode: reject_unlisted` with
`allow_promotion: [/proc]`: `/proc` may be promoted from read-only to
read-write for GPU runtime needs, while device-node conflicts are rejected
unless the policy explicitly allows a matching promotion pattern or sets
`mode: promote_all`. `mode: reject_all` disables promotion entirely.

The promotion allow list is not an access grant by itself. It only applies to
paths that are already part of the active OpenShell runtime baseline.

## Network Decisions

Ordinary network traffic follows this order:
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-policy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
serde_yml = { workspace = true }
miette = { workspace = true }
glob = { workspace = true }

[lints]
workspace = true
208 changes: 207 additions & 1 deletion crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::proto::{
FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule,
LandlockPolicy, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy,
SandboxPolicy,
ReadOnlyToReadWriteConflictPolicy, RuntimeBaselineConflicts, SandboxPolicy,
};
use serde::{Deserialize, Serialize};

Expand All @@ -30,6 +30,11 @@ pub use merge::{
merge_policy, policy_covers_rule,
};

pub const RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED: &str = "reject_unlisted";
pub const RUNTIME_BASELINE_CONFLICT_MODE_PROMOTE_ALL: &str = "promote_all";
pub const RUNTIME_BASELINE_CONFLICT_MODE_REJECT_ALL: &str = "reject_all";
pub const DEFAULT_RUNTIME_BASELINE_ALLOW_PROMOTION: &[&str] = &["/proc"];

// ---------------------------------------------------------------------------
// YAML serde types (canonical — used for both parsing and serialization)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -57,6 +62,24 @@ struct FilesystemDef {
read_only: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
read_write: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
runtime_baseline_conflicts: Option<RuntimeBaselineConflictsDef>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RuntimeBaselineConflictsDef {
#[serde(default, skip_serializing_if = "Option::is_none")]
read_only_to_read_write: Option<ReadOnlyToReadWriteConflictPolicyDef>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadOnlyToReadWriteConflictPolicyDef {
#[serde(default, skip_serializing_if = "String::is_empty")]
mode: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allow_promotion: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -366,6 +389,9 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
include_workdir: fs.include_workdir,
read_only: fs.read_only,
read_write: fs.read_write,
runtime_baseline_conflicts: fs
.runtime_baseline_conflicts
.map(runtime_baseline_conflicts_to_proto),
}),
landlock: raw.landlock.map(|ll| LandlockPolicy {
compatibility: ll.compatibility,
Expand All @@ -378,6 +404,32 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
}
}

fn runtime_baseline_conflicts_to_proto(
conflicts: RuntimeBaselineConflictsDef,
) -> RuntimeBaselineConflicts {
RuntimeBaselineConflicts {
read_only_to_read_write: conflicts.read_only_to_read_write.map(|policy| {
ReadOnlyToReadWriteConflictPolicy {
mode: policy.mode,
allow_promotion: policy.allow_promotion,
}
}),
}
}

fn runtime_baseline_conflicts_from_proto(
conflicts: &RuntimeBaselineConflicts,
) -> RuntimeBaselineConflictsDef {
RuntimeBaselineConflictsDef {
read_only_to_read_write: conflicts.read_only_to_read_write.as_ref().map(|policy| {
ReadOnlyToReadWriteConflictPolicyDef {
mode: policy.mode.clone(),
allow_promotion: policy.allow_promotion.clone(),
}
}),
}
}

// ---------------------------------------------------------------------------
// Proto → YAML conversion
// ---------------------------------------------------------------------------
Expand All @@ -387,6 +439,10 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
include_workdir: fs.include_workdir,
read_only: fs.read_only.clone(),
read_write: fs.read_write.clone(),
runtime_baseline_conflicts: fs
.runtime_baseline_conflicts
.as_ref()
.map(runtime_baseline_conflicts_from_proto),
});

let landlock = policy.landlock.as_ref().map(|ll| LandlockDef {
Expand Down Expand Up @@ -637,6 +693,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy {
"/var/log".into(),
],
read_write: vec!["/sandbox".into(), "/tmp".into(), "/dev/null".into()],
runtime_baseline_conflicts: None,
}),
landlock: Some(LandlockPolicy {
compatibility: "best_effort".into(),
Expand Down Expand Up @@ -691,6 +748,10 @@ pub enum PolicyViolation {
TooManyPaths { count: usize },
/// A network endpoint uses a TLD wildcard (e.g. `*.com`).
TldWildcard { policy_name: String, host: String },
/// Runtime baseline read-only conflict mode is not recognized.
InvalidRuntimeBaselineConflictMode { value: String },
/// Runtime baseline promotion pattern is invalid.
InvalidRuntimeBaselinePromotionPattern { pattern: String, reason: String },
}

impl fmt::Display for PolicyViolation {
Expand Down Expand Up @@ -727,6 +788,21 @@ impl fmt::Display for PolicyViolation {
use subdomain wildcards like '*.example.com' instead"
)
}
Self::InvalidRuntimeBaselineConflictMode { value } => {
write!(
f,
"runtime baseline read_only_to_read_write mode must be one of \
'{RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED}', \
'{RUNTIME_BASELINE_CONFLICT_MODE_PROMOTE_ALL}', or \
'{RUNTIME_BASELINE_CONFLICT_MODE_REJECT_ALL}', got '{value}'"
)
}
Self::InvalidRuntimeBaselinePromotionPattern { pattern, reason } => {
write!(
f,
"runtime baseline promotion pattern is invalid: {pattern} ({reason})"
)
}
}
}
}
Expand All @@ -744,6 +820,8 @@ impl fmt::Display for PolicyViolation {
/// - Read-write paths must not be overly broad (just `/`)
/// - Individual path lengths must not exceed [`MAX_PATH_LENGTH`]
/// - Total path count must not exceed [`MAX_FILESYSTEM_PATHS`]
/// - Runtime baseline conflict controls must use known modes and absolute
/// promotion patterns without `..`
/// - Network endpoint hosts must not use TLD wildcards (e.g. `*.com`)
pub fn validate_sandbox_policy(
policy: &SandboxPolicy,
Expand Down Expand Up @@ -812,6 +890,12 @@ pub fn validate_sandbox_policy(
});
}
}

if let Some(conflicts) = &fs.runtime_baseline_conflicts
&& let Some(policy) = &conflicts.read_only_to_read_write
{
validate_runtime_baseline_conflict_policy(policy, &mut violations);
}
}

// Check network policy endpoint hosts for TLD wildcards.
Expand Down Expand Up @@ -841,6 +925,55 @@ pub fn validate_sandbox_policy(
}
}

fn validate_runtime_baseline_conflict_policy(
policy: &ReadOnlyToReadWriteConflictPolicy,
violations: &mut Vec<PolicyViolation>,
) {
let mode = policy.mode.as_str();
if !mode.is_empty()
&& mode != RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED
&& mode != RUNTIME_BASELINE_CONFLICT_MODE_PROMOTE_ALL
&& mode != RUNTIME_BASELINE_CONFLICT_MODE_REJECT_ALL
{
violations.push(PolicyViolation::InvalidRuntimeBaselineConflictMode {
value: policy.mode.clone(),
});
}

for pattern in &policy.allow_promotion {
if pattern.is_empty() {
violations.push(PolicyViolation::InvalidRuntimeBaselinePromotionPattern {
pattern: pattern.clone(),
reason: "pattern must not be empty".to_string(),
});
continue;
}

let path = Path::new(pattern);
if !path.has_root() {
violations.push(PolicyViolation::InvalidRuntimeBaselinePromotionPattern {
pattern: pattern.clone(),
reason: "pattern must be absolute".to_string(),
});
}
if path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
violations.push(PolicyViolation::InvalidRuntimeBaselinePromotionPattern {
pattern: pattern.clone(),
reason: "pattern must not contain '..' components".to_string(),
});
}
if let Err(error) = glob::Pattern::new(pattern) {
violations.push(PolicyViolation::InvalidRuntimeBaselinePromotionPattern {
pattern: pattern.clone(),
reason: error.to_string(),
});
}
}
}

/// Truncate a string for safe inclusion in error messages.
fn truncate_for_display(s: &str) -> String {
if s.len() <= 80 {
Expand Down Expand Up @@ -973,6 +1106,38 @@ network_policies:
assert_eq!(proto2.network_policies["my_api"].name, "my-custom-api-name");
}

#[test]
fn round_trip_preserves_runtime_baseline_conflicts() {
let yaml = r#"
version: 1
filesystem_policy:
include_workdir: true
read_only: [/usr, /proc]
read_write: [/sandbox, /tmp]
runtime_baseline_conflicts:
read_only_to_read_write:
mode: reject_unlisted
allow_promotion:
- /proc
- "/dev/nvidia*"
"#;
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");

let conflicts = proto2
.filesystem
.as_ref()
.and_then(|fs| fs.runtime_baseline_conflicts.as_ref())
.and_then(|conflicts| conflicts.read_only_to_read_write.as_ref())
.expect("runtime conflict policy");
assert_eq!(
conflicts.mode,
RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED
);
assert_eq!(conflicts.allow_promotion, vec!["/proc", "/dev/nvidia*"]);
}

#[test]
fn restrictive_default_has_no_network_policies() {
let policy = restrictive_default_policy();
Expand Down Expand Up @@ -1204,6 +1369,7 @@ network_policies:
include_workdir: true,
read_only: vec!["/usr/../etc/shadow".into()],
read_write: vec!["/tmp".into()],
..Default::default()
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
Expand All @@ -1220,6 +1386,7 @@ network_policies:
include_workdir: true,
read_only: vec!["usr/lib".into()],
read_write: vec!["/tmp".into()],
..Default::default()
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
Expand All @@ -1236,6 +1403,7 @@ network_policies:
include_workdir: true,
read_only: vec!["/usr".into()],
read_write: vec!["/".into()],
..Default::default()
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
Expand Down Expand Up @@ -1282,6 +1450,7 @@ network_policies:
include_workdir: true,
read_only: many_paths,
read_write: vec!["/tmp".into()],
..Default::default()
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
Expand All @@ -1291,6 +1460,42 @@ network_policies:
);
}

#[test]
fn validate_rejects_invalid_runtime_baseline_conflict_mode() {
let mut policy = restrictive_default_policy();
let fs = policy.filesystem.as_mut().expect("filesystem policy");
fs.runtime_baseline_conflicts = Some(RuntimeBaselineConflicts {
read_only_to_read_write: Some(ReadOnlyToReadWriteConflictPolicy {
mode: "ask".into(),
allow_promotion: vec!["/proc".into()],
}),
});

let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(violations.iter().any(|v| matches!(
v,
PolicyViolation::InvalidRuntimeBaselineConflictMode { .. }
)));
}

#[test]
fn validate_rejects_invalid_runtime_baseline_promotion_pattern() {
let mut policy = restrictive_default_policy();
let fs = policy.filesystem.as_mut().expect("filesystem policy");
fs.runtime_baseline_conflicts = Some(RuntimeBaselineConflicts {
read_only_to_read_write: Some(ReadOnlyToReadWriteConflictPolicy {
mode: RUNTIME_BASELINE_CONFLICT_MODE_REJECT_UNLISTED.into(),
allow_promotion: vec!["dev/nvidia*".into(), "/proc/../etc".into()],
}),
});

let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(violations.iter().any(|v| matches!(
v,
PolicyViolation::InvalidRuntimeBaselinePromotionPattern { .. }
)));
}

#[test]
fn validate_rejects_path_too_long() {
let mut policy = restrictive_default_policy();
Expand All @@ -1299,6 +1504,7 @@ network_policies:
include_workdir: true,
read_only: vec![long_path],
read_write: vec!["/tmp".into()],
..Default::default()
});
let violations = validate_sandbox_policy(&policy).unwrap_err();
assert!(
Expand Down
Loading
Loading