Skip to content
Merged
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
12 changes: 11 additions & 1 deletion crates/execution/src/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,12 @@ pub struct GuestRuntimeConfig {
/// keeps the bridge-only snapshot (unchanged behavior). The runtime caches the
/// snapshot process-wide keyed by sha256(bridge_code + this bundle).
pub snapshot_userland_code: Option<String>,
/// Optional sidecar-owned classic script evaluated in the execution's
/// context before the entrypoint, including before an ES module entrypoint
/// links its static imports. The sidecar uses it to define VM-wide guest
/// globals such as host-function collections. Runner isolates for WASM and
/// Python leave it unset.
pub prelude_script: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -2954,7 +2960,11 @@ impl JavascriptExecutionEngine {
mode: u8::from(use_module_mode) | if retain_context { 2 } else { 0 },
file_path: execution_file_path,
bridge_code: V8RuntimeHost::bridge_code().to_owned(),
post_restore_script: String::new(),
post_restore_script: request
.guest_runtime
.prelude_script
.clone()
.unwrap_or_default(),
userland_code: snapshot_userland_code,
high_resolution_time: request.guest_runtime.high_resolution_time,
user_code,
Expand Down
166 changes: 136 additions & 30 deletions crates/native-sidecar-core/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,35 @@ pub fn allow_all_policy() -> vm_config::PermissionsPolicy {
}
}

/// The network scope that keeps the VM self-contained: guest listeners and
/// VM-local loopback traffic work, and everything that would cross the VM
/// boundary is denied. Listeners are never published on the host, and guest
/// loopback reaches a host loopback service only through an explicit
/// loopback exemption. It is the default network scope and the meaning of a
/// scalar `network: "deny"`.
pub fn vm_local_network_scope() -> vm_config::PatternPermissionScope {
vm_config::PatternPermissionScope::Rules(vm_config::PatternPermissionRuleSet {
default: Some(vm_config::PermissionMode::Deny),
rules: vec![
vm_config::PatternPermissionRule {
mode: vm_config::PermissionMode::Allow,
operations: vec![String::from("listen")],
patterns: vec![String::from("tcp://**"), String::from("unix:**")],
},
vm_config::PatternPermissionRule {
mode: vm_config::PermissionMode::Allow,
operations: vec![String::from("http")],
patterns: vec![
String::from("tcp://127.0.0.1:*"),
String::from("tcp://localhost:*"),
String::from("tcp://::1:*"),
String::from("unix:**"),
],
},
],
})
}

/// The policy for every scope a client leaves out. The guest behaves like a
/// sandboxed machine: its virtual filesystem, processes, environment, host
/// functions, listeners, and loopback networking work. External network access
Expand All @@ -69,28 +98,7 @@ pub fn default_permissions_policy() -> vm_config::PermissionsPolicy {
fs: Some(vm_config::FsPermissionScope::Mode(
vm_config::PermissionMode::Allow,
)),
network: Some(vm_config::PatternPermissionScope::Rules(
vm_config::PatternPermissionRuleSet {
default: Some(vm_config::PermissionMode::Deny),
rules: vec![
vm_config::PatternPermissionRule {
mode: vm_config::PermissionMode::Allow,
operations: vec![String::from("listen")],
patterns: vec![String::from("tcp://**"), String::from("unix:**")],
},
vm_config::PatternPermissionRule {
mode: vm_config::PermissionMode::Allow,
operations: vec![String::from("http")],
patterns: vec![
String::from("tcp://127.0.0.1:*"),
String::from("tcp://localhost:*"),
String::from("tcp://::1:*"),
String::from("unix:**"),
],
},
],
},
)),
network: Some(vm_local_network_scope()),
child_process: allow(),
process: allow(),
env: allow(),
Expand Down Expand Up @@ -137,8 +145,8 @@ pub fn resolve_permissions_policy(
if overrides.fs.is_some() {
resolved.fs = overrides.fs;
}
if overrides.network.is_some() {
resolved.network = overrides.network;
if let Some(network) = overrides.network {
resolved.network = Some(resolve_network_override(network));
}
if overrides.child_process.is_some() {
resolved.child_process = overrides.child_process;
Expand All @@ -156,6 +164,26 @@ pub fn resolve_permissions_policy(
resolved
}

/// A scalar network denial denies external egress only. Like the default, it
/// keeps VM-local listeners and loopback working, because they never cross
/// the VM boundary. A caller that also wants to deny those lists explicit
/// rules instead.
fn resolve_network_override(
network: vm_config::PatternPermissionScope,
) -> vm_config::PatternPermissionScope {
match network {
vm_config::PatternPermissionScope::Mode(vm_config::PermissionMode::Deny) => {
vm_local_network_scope()
}
vm_config::PatternPermissionScope::Mode(
mode @ (vm_config::PermissionMode::Allow | vm_config::PermissionMode::Ask),
) => vm_config::PatternPermissionScope::Mode(mode),
vm_config::PatternPermissionScope::Rules(rules) => {
vm_config::PatternPermissionScope::Rules(rules)
}
}
}

pub fn evaluate_permissions_policy(
permissions: &vm_config::PermissionsPolicy,
domain: &str,
Expand Down Expand Up @@ -627,7 +655,10 @@ mod tests {
"dns://generativelanguage.googleapis.com",
"dns://openrouter.ai",
] {
assert!(network.rules[0]
assert!(network
.rules
.last()
.expect("agentOS egress rule")
.patterns
.iter()
.any(|value| value == expected));
Expand All @@ -648,12 +679,87 @@ mod tests {
);
assert_eq!(overridden.fs, defaults.fs);
assert_eq!(overridden.child_process, defaults.child_process);
assert_eq!(
overridden.network,
Some(vm_config::PatternPermissionScope::Mode(
vm_config::PermissionMode::Deny
))
assert_eq!(overridden.network, Some(vm_local_network_scope()));
}

fn network_override(scope: vm_config::PatternPermissionScope) -> vm_config::PermissionsPolicy {
vm_config::PermissionsPolicy {
fs: None,
network: Some(scope),
child_process: None,
process: None,
env: None,
host_function: None,
}
}

#[test]
fn scalar_network_deny_blocks_egress_but_keeps_vm_local_networking() {
for profile in [
vm_config::VmDefaultsProfile::Secure,
vm_config::VmDefaultsProfile::AgentOs,
] {
let policy = resolve_permissions_policy(
profile,
Some(network_override(vm_config::PatternPermissionScope::Mode(
vm_config::PermissionMode::Deny,
))),
);

for (capability, resource) in [
("network.listen", "tcp://0.0.0.0:3000"),
("network.listen", "tcp://127.0.0.1:3000"),
("network.listen", "unix:/tmp/app.sock"),
("network.http", "tcp://127.0.0.1:3000"),
("network.http", "tcp://localhost:3000"),
("network.http", "tcp://::1:3000"),
("network.http", "unix:/tmp/app.sock"),
] {
assert!(
allowed(&policy, "network", capability, resource),
"{profile:?}: {capability} {resource} should stay VM-local and allowed",
);
}
for (capability, resource) in [
("network.http", "tcp://example.com:443"),
("network.http", "tcp://203.0.113.10:80"),
("network.http", "tcp://api.anthropic.com:443"),
("network.fetch", "https://example.com/"),
("network.dns", "dns://example.com"),
("network.dns", "dns://api.anthropic.com"),
] {
assert!(
!allowed(&policy, "network", capability, resource),
"{profile:?}: {capability} {resource} crosses the VM boundary",
);
}
}
}

#[test]
fn explicit_network_rules_can_still_deny_vm_local_networking() {
let policy = resolve_permissions_policy(
vm_config::VmDefaultsProfile::Secure,
Some(network_override(vm_config::PatternPermissionScope::Rules(
vm_config::PatternPermissionRuleSet {
default: Some(vm_config::PermissionMode::Deny),
rules: Vec::new(),
},
))),
);

assert!(!allowed(
&policy,
"network",
"network.listen",
"tcp://127.0.0.1:3000"
));
assert!(!allowed(
&policy,
"network",
"network.http",
"tcp://127.0.0.1:3000"
));
}

#[test]
Expand Down
6 changes: 3 additions & 3 deletions crates/native-sidecar/src/execution/child_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4893,7 +4893,7 @@ where
let execution_result = javascript_engine
.start_execution_with_module_reader_and_runtime(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(
guest_runtime: javascript_guest_runtime(
&vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
Expand Down Expand Up @@ -5575,7 +5575,7 @@ where
let replacement_result = javascript_engine
.prepare_execution_with_module_reader_and_runtime(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(
guest_runtime: javascript_guest_runtime(
vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
Expand Down Expand Up @@ -6515,7 +6515,7 @@ where
let execution_result = javascript_engine
.start_execution_with_module_reader_and_runtime(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(
guest_runtime: javascript_guest_runtime(
&vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
Expand Down
19 changes: 18 additions & 1 deletion crates/native-sidecar/src/execution/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3023,9 +3023,26 @@ pub(super) fn guest_runtime_identity(
.as_ref()
.is_some_and(|cfg| cfg.high_resolution_time.unwrap_or(false)),
snapshot_userland_code: None,
prelude_script: None,
}
}

/// Guest-runtime config for a guest Node process. Adds the VM's host-function
/// globals to the shared identity so every guest Node process, however it was
/// launched, can call registered host functions directly.
pub(super) fn javascript_guest_runtime(
vm: &VmState,
virtual_pid: Option<u64>,
virtual_ppid: Option<u64>,
) -> GuestRuntimeConfig {
let mut config = guest_runtime_identity(vm, virtual_pid, virtual_ppid);
let script = host_function_globals_script(&vm.host_functions);
if !script.is_empty() {
config.prelude_script = Some(script);
}
config
}

/// The guest's virtual home directory, sourced from the VM user profile (the
/// same value carried to the guest as `os.homedir()` via `guest_runtime`). Used
/// by sidecar-internal `~`-path resolution; falls back to `/root` for a
Expand Down Expand Up @@ -5076,7 +5093,7 @@ where
let execution = javascript_engine
.start_execution_with_module_reader_and_runtime(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(&vm, None, None),
guest_runtime: javascript_guest_runtime(&vm, None, None),
vm_id: vm_id.clone(),
context_id: context.context_id,
argv: std::iter::once(launch_entrypoint.clone())
Expand Down
2 changes: 1 addition & 1 deletion crates/native-sidecar/src/execution/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ use crate::filesystem::{
service_owned_python_filesystem_rpc_request,
};
use crate::host_functions::{
format_host_function_failure_output, is_host_function_command,
format_host_function_failure_output, host_function_globals_script, is_host_function_command,
normalized_host_function_command_name, resolve_host_function_command,
HostFunctionCommandResolution,
};
Expand Down
Loading