Skip to content
Draft
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ typed subprocess capability and separately grants executable mappings. On
macOS, subprocess compatibility includes broad Mach lookup and may reach
same-user local services even when IP networking is blocked.

Foreground terminal compatibility and subprocess creation are independent
capabilities. Product runtime preparation must never enable subprocesses as an
implicit consequence of selecting inherited terminal behavior.

## Security invariants

These rules are release-blocking:
Expand Down
7 changes: 5 additions & 2 deletions crates/cli/src/execution/supervised.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{

use sandy_core::{
AccessMode, CommandSpec, LaunchManifestV2, MANIFEST_SCHEMA_V2, NetworkPolicy, OsValue,
PathScope, ValidatedLaunch, encode_launch,
PathScope, SandboxPolicy, ValidatedLaunch, encode_launch,
};
use serde_json::json;
use tempfile::Builder;
Expand Down Expand Up @@ -83,7 +83,10 @@ pub(crate) fn run(arguments: RunArgs) -> Result<i32, AppError> {
} else {
NetworkPolicy::AllowAll
};
let mut intent = runtime::intent(network);
// Foreground launching and subprocess creation are independent policy
// capabilities. The default CLI contract deliberately selects both.
let policy = SandboxPolicy::new(network).allow_subprocesses();
let mut intent = runtime::intent(policy);
intent = intent.grant_file_and_execute(
paths.working_directory.as_path(),
AccessMode::ReadWrite,
Expand Down
32 changes: 18 additions & 14 deletions crates/cli/src/resolve/runtime/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

use std::path::Path;

use sandy_core::{
AccessMode, NetworkPolicy, PathScope, SandboxPolicy, allow_foreground_cli_compatibility,
};
use sandy_core::{AccessMode, PathScope, SandboxPolicy, allow_foreground_cli_compatibility};

use crate::resolve::CliPolicyIntent;

Expand Down Expand Up @@ -54,12 +52,12 @@ const READ_ONLY_DATA_FILES: &[&str] = &[
/// CLI product. Only named runtime devices are exposed; the rest of `/dev` and
/// the host process tree remain absent. Inherited standard streams and their
/// controlling terminal remain caller-held capabilities.
pub(crate) fn intent(network: NetworkPolicy) -> CliPolicyIntent {
add_matching(network, |path| Path::new(path).exists())
pub(crate) fn intent(policy: SandboxPolicy) -> CliPolicyIntent {
add_matching(policy, |path| Path::new(path).exists())
}

fn add_matching(network: NetworkPolicy, mut include: impl FnMut(&str) -> bool) -> CliPolicyIntent {
let policy = allow_foreground_cli_compatibility(SandboxPolicy::new(network));
fn add_matching(policy: SandboxPolicy, mut include: impl FnMut(&str) -> bool) -> CliPolicyIntent {
let policy = allow_foreground_cli_compatibility(policy);
let mut intent = CliPolicyIntent::new(policy);
for path in READ_EXECUTE_SUBTREES {
if include(path) {
Expand Down Expand Up @@ -91,7 +89,7 @@ fn add_matching(network: NetworkPolicy, mut include: impl FnMut(&str) -> bool) -

#[cfg(test)]
mod tests {
use sandy_core::{FileMetadataPolicy, RuntimeCompatibility};
use sandy_core::{FileMetadataPolicy, NetworkPolicy, RuntimeCompatibility};

use crate::resolve::resolve_policy;

Expand All @@ -101,7 +99,10 @@ mod tests {
fn baseline_is_explicit_and_contains_only_named_devices()
-> Result<(), Box<dyn std::error::Error>> {
let policy = resolve_policy(
add_matching(NetworkPolicy::BlockAll, |path| Path::new(path).exists()),
add_matching(
SandboxPolicy::new(NetworkPolicy::BlockAll).allow_subprocesses(),
|path| Path::new(path).exists(),
),
&[],
)?
.finish()?
Expand Down Expand Up @@ -152,11 +153,14 @@ mod tests {
#[test]
fn data_paths_never_gain_executable_authority() -> Result<(), Box<dyn std::error::Error>> {
let policy = resolve_policy(
add_matching(NetworkPolicy::BlockAll, |path| {
Path::new(path).exists()
&& (READ_ONLY_DATA_SUBTREES.contains(&path)
|| READ_ONLY_DATA_FILES.contains(&path))
}),
add_matching(
SandboxPolicy::new(NetworkPolicy::BlockAll).allow_subprocesses(),
|path| {
Path::new(path).exists()
&& (READ_ONLY_DATA_SUBTREES.contains(&path)
|| READ_ONLY_DATA_FILES.contains(&path))
},
),
&[],
)?
.finish()?
Expand Down
38 changes: 24 additions & 14 deletions crates/cli/src/resolve/runtime/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
use std::path::Path;

use sandy_core::{
AccessMode, NetworkPolicy, PathScope, SandboxPolicy, allow_file_metadata,
allow_foreground_cli_compatibility,
AccessMode, PathScope, SandboxPolicy, allow_file_metadata, allow_foreground_cli_compatibility,
};

use crate::resolve::CliPolicyIntent;
Expand All @@ -29,14 +28,16 @@ const READ_WRITE_LITERALS: &[&str] = &[
];

/// Adds the CLI's ordinary command-runtime permissions through the shared
/// policy builder. These entries have no privileged precedence over profile or
/// user policy, and terminal denies still override them.
pub(crate) fn intent(network: NetworkPolicy) -> CliPolicyIntent {
add_matching(network, |path| Path::new(path).exists())
/// policy builder. These entries have no privileged precedence over caller
/// policy, and terminal denies still override them.
pub(crate) fn intent(policy: SandboxPolicy) -> CliPolicyIntent {
add_matching(policy, |path| Path::new(path).exists())
}

fn add_matching(network: NetworkPolicy, mut include: impl FnMut(&str) -> bool) -> CliPolicyIntent {
let mut policy = SandboxPolicy::new(network);
fn add_matching(
mut policy: SandboxPolicy,
mut include: impl FnMut(&str) -> bool,
) -> CliPolicyIntent {
policy = allow_file_metadata(policy);
policy = allow_foreground_cli_compatibility(policy);
let mut intent =
Expand Down Expand Up @@ -69,9 +70,15 @@ mod tests {

#[test]
fn baseline_is_only_explicit_policy_input() -> Result<(), Box<dyn std::error::Error>> {
let policy = resolve_policy(add_matching(NetworkPolicy::BlockAll, |_| false), &[])?
.finish()?
.into_spec();
let policy = resolve_policy(
add_matching(
SandboxPolicy::new(NetworkPolicy::BlockAll).allow_subprocesses(),
|_| false,
),
&[],
)?
.finish()?
.into_spec();

assert!(policy.files.iter().any(|grant| {
grant.path.as_path() == std::path::Path::new("/")
Expand All @@ -94,9 +101,12 @@ mod tests {
fn data_and_device_paths_do_not_gain_executable_authority()
-> Result<(), Box<dyn std::error::Error>> {
let policy = resolve_policy(
add_matching(NetworkPolicy::BlockAll, |path| {
READ_ONLY_DATA_SUBTREES.contains(&path) || READ_WRITE_LITERALS.contains(&path)
}),
add_matching(
SandboxPolicy::new(NetworkPolicy::BlockAll).allow_subprocesses(),
|path| {
READ_ONLY_DATA_SUBTREES.contains(&path) || READ_WRITE_LITERALS.contains(&path)
},
),
&[],
)?
.finish()?
Expand Down
14 changes: 6 additions & 8 deletions crates/cli/src/resolve/runtime/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
//! Product-owned runtime compatibility policy.

use sandy_core::NetworkPolicy;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
use sandy_core::SandboxPolicy;

use crate::resolve::CliPolicyIntent;
Expand All @@ -12,16 +10,16 @@ pub(crate) mod linux;
pub(crate) mod macos;

#[cfg(target_os = "linux")]
pub(crate) fn intent(network: NetworkPolicy) -> CliPolicyIntent {
linux::intent(network)
pub(crate) fn intent(policy: SandboxPolicy) -> CliPolicyIntent {
linux::intent(policy)
}

#[cfg(target_os = "macos")]
pub(crate) fn intent(network: NetworkPolicy) -> CliPolicyIntent {
macos::intent(network)
pub(crate) fn intent(policy: SandboxPolicy) -> CliPolicyIntent {
macos::intent(policy)
}

#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn intent(network: NetworkPolicy) -> CliPolicyIntent {
CliPolicyIntent::new(SandboxPolicy::new(network))
pub(crate) fn intent(policy: SandboxPolicy) -> CliPolicyIntent {
CliPolicyIntent::new(policy)
}
20 changes: 19 additions & 1 deletion crates/core/src/sandbox_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,13 @@ pub fn allow_file_metadata(mut policy: SandboxPolicy) -> SandboxPolicy {

/// Enables the CLI's foreground compatibility behavior.
///
/// This does not grant subprocess authority. The product boundary must select
/// that independent capability explicitly when its execution contract needs
/// descendants.
///
/// This function is intentionally not re-exported by the supported facade.
#[doc(hidden)]
pub fn allow_foreground_cli_compatibility(mut policy: SandboxPolicy) -> SandboxPolicy {
policy.allow_subprocesses = true;
policy.runtime_compatibility = crate::RuntimeCompatibility::ForegroundCli;
policy
}
Expand Down Expand Up @@ -546,6 +549,21 @@ mod tests {
Ok(())
}

#[test]
fn foreground_compatibility_does_not_grant_subprocesses()
-> Result<(), Box<dyn std::error::Error>> {
let parts = into_policy_parts(allow_foreground_cli_compatibility(SandboxPolicy::new(
NetworkPolicy::BlockAll,
)))?;

assert!(!parts.allow_subprocesses);
assert_eq!(
parts.runtime_compatibility,
crate::RuntimeCompatibility::ForegroundCli
);
Ok(())
}

#[test]
fn bounds_document_bytes_and_capabilities() {
let oversized = vec![b' '; MAX_POLICY_DOCUMENT_BYTES + 1];
Expand Down