diff --git a/crates/execution/src/javascript.rs b/crates/execution/src/javascript.rs index d16d075b93..8303f62037 100644 --- a/crates/execution/src/javascript.rs +++ b/crates/execution/src/javascript.rs @@ -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, + /// 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, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -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, diff --git a/crates/native-sidecar-core/src/permissions.rs b/crates/native-sidecar-core/src/permissions.rs index ee9de7b3ca..b6ca05c5e3 100644 --- a/crates/native-sidecar-core/src/permissions.rs +++ b/crates/native-sidecar-core/src/permissions.rs @@ -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 @@ -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(), @@ -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; @@ -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, @@ -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)); @@ -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] diff --git a/crates/native-sidecar/src/execution/child_process.rs b/crates/native-sidecar/src/execution/child_process.rs index b69a626ba0..60e7e8b60e 100644 --- a/crates/native-sidecar/src/execution/child_process.rs +++ b/crates/native-sidecar/src/execution/child_process.rs @@ -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)), @@ -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)), @@ -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)), diff --git a/crates/native-sidecar/src/execution/launch.rs b/crates/native-sidecar/src/execution/launch.rs index cf087ba904..109ffc9085 100644 --- a/crates/native-sidecar/src/execution/launch.rs +++ b/crates/native-sidecar/src/execution/launch.rs @@ -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, + virtual_ppid: Option, +) -> 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 @@ -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()) diff --git a/crates/native-sidecar/src/execution/mod.rs b/crates/native-sidecar/src/execution/mod.rs index 2a2a0034dc..60495fb929 100644 --- a/crates/native-sidecar/src/execution/mod.rs +++ b/crates/native-sidecar/src/execution/mod.rs @@ -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, }; diff --git a/crates/native-sidecar/src/host_functions.rs b/crates/native-sidecar/src/host_functions.rs index dc26956a13..4b3641f2ba 100644 --- a/crates/native-sidecar/src/host_functions.rs +++ b/crates/native-sidecar/src/host_functions.rs @@ -715,6 +715,59 @@ fn host_function_registration_error(error: HostFunctionRegistrationError) -> Sid } } +/// Convert a kebab-case host function or collection name to the camelCase +/// identifier +/// guest JavaScript uses for it. +fn host_function_identifier(name: &str) -> String { + let mut identifier = String::with_capacity(name.len()); + let mut upper_next = false; + for character in name.chars() { + if character == '-' { + upper_next = true; + } else if upper_next { + identifier.extend(character.to_uppercase()); + upper_next = false; + } else { + identifier.push(character); + } + } + identifier +} + +/// Guest script that defines one frozen global per registered host function +/// collection, so guest JavaScript can `await orders.list({ ... })` instead of +/// spawning the `agentos-orders` command by hand. The sidecar runs it before +/// the entrypoint of every guest Node process in the VM. Each function runs +/// that same command, so the `hostFunction` permission scope, input +/// validation, and timeouts all still apply. A collection whose name is +/// already a global is skipped with a warning on stderr rather than shadowing +/// it. Returns an empty string when the VM has no host functions. +pub(crate) fn host_function_globals_script( + host_functions: &BTreeMap, +) -> String { + const DEFINE_GLOBALS: &str = r#"((groups)=>{const call=(command,name)=>(input={})=>import("node:child_process").then(({execFile})=>new Promise((resolve,reject)=>{execFile(command,[name,"--json",JSON.stringify(input)],{maxBuffer:67108864},(error,stdout,stderr)=>{if(error){reject(new Error(String(stderr||"").trim()||error.message));return;}let reply;try{reply=JSON.parse(String(stdout));}catch(parseError){reject(new Error("host function returned invalid JSON: "+parseError.message));return;}if(!reply||reply.ok!==true){reject(new Error((reply&&reply.error)||"host function failed"));return;}resolve(reply.result);});}));for(const [group,definition] of Object.entries(groups)){if(group in globalThis){console.error("agentos: host function group \""+group+"\" was not defined because globalThis."+group+" already exists");continue;}const functions={};for(const [key,name] of definition.functions)functions[key]=call(definition.command,name);Object.defineProperty(globalThis,group,{value:Object.freeze(functions),enumerable:true});}})"#; + + let groups = host_functions + .values() + .filter_map(|collection| { + let command = collection.command_aliases.first()?; + let functions = collection + .callbacks + .keys() + .map(|name| serde_json::json!([host_function_identifier(name), name])) + .collect::>(); + Some(( + host_function_identifier(&collection.name), + serde_json::json!({ "command": command, "functions": functions }), + )) + }) + .collect::>(); + if groups.is_empty() { + return String::new(); + } + format!("{DEFINE_GLOBALS}({});", serde_json::Value::Object(groups)) +} + enum HostFunctionCommand { Registry(String), Collection { collection_name: String }, @@ -1042,3 +1095,60 @@ mod tests { ); } } + +#[cfg(test)] +mod host_function_globals_tests { + use super::*; + use crate::protocol::RegisteredHostCallbackDefinition; + + fn collection(name: &str, callbacks: &[&str]) -> RegisterHostCallbacksRequest { + RegisterHostCallbacksRequest { + name: name.to_string(), + description: String::new(), + command_aliases: vec![format!("agentos-{name}")], + registry_command_aliases: vec![String::from("agentos")], + callbacks: callbacks + .iter() + .map(|callback| { + ( + callback.to_string(), + RegisteredHostCallbackDefinition { + description: String::new(), + input_schema: String::from("{}"), + timeout_ms: None, + examples: Vec::new(), + }, + ) + }) + .collect(), + } + } + + #[test] + fn converts_kebab_case_names_to_identifiers() { + assert_eq!(host_function_identifier("orders"), "orders"); + assert_eq!(host_function_identifier("order-store"), "orderStore"); + assert_eq!( + host_function_identifier("list-open-orders"), + "listOpenOrders" + ); + } + + #[test] + fn is_empty_without_host_functions() { + assert_eq!(host_function_globals_script(&BTreeMap::new()), ""); + } + + #[test] + fn defines_one_group_per_collection_on_a_single_line() { + let host_functions = BTreeMap::from([( + String::from("order-store"), + collection("order-store", &["list-orders"]), + )]); + let script = host_function_globals_script(&host_functions); + assert!(!script.contains('\n'), "script must stay on one line"); + assert!(script.ends_with( + r#"({"orderStore":{"command":"agentos-order-store","functions":[["listOrders","list-orders"]]}});"# + )); + } +} diff --git a/crates/native-sidecar/src/language_execution.rs b/crates/native-sidecar/src/language_execution.rs index 8a37a2fd6e..3e91972dca 100644 --- a/crates/native-sidecar/src/language_execution.rs +++ b/crates/native-sidecar/src/language_execution.rs @@ -115,59 +115,6 @@ fn inline_inputs_prefix(inputs: Option, python: bool) -> String { } } -/// Convert a kebab-case host function or collection name to the camelCase -/// identifier -/// guest JavaScript uses for it. -fn host_function_identifier(name: &str) -> String { - let mut identifier = String::with_capacity(name.len()); - let mut upper_next = false; - for character in name.chars() { - if character == '-' { - upper_next = true; - } else if upper_next { - identifier.extend(character.to_uppercase()); - upper_next = false; - } else { - identifier.push(character); - } - } - identifier -} - -/// Guest prefix that defines one frozen global per registered host function -/// collection, so inline JavaScript can `await orders.list({ ... })` instead of -/// spawning the `agentos-orders` command by hand. Each function runs that same -/// command, so the `hostFunction` permission scope, input validation, and -/// timeouts -/// all still apply. The prefix stays on one line so guest line numbers do not -/// shift. A collection whose name is already a global is skipped with a warning -/// on stderr rather than shadowing it. -fn inline_host_functions_prefix( - host_functions: &BTreeMap, -) -> String { - const DEFINE_GLOBALS: &str = r#"((groups)=>{const call=(command,name)=>(input={})=>import("node:child_process").then(({execFile})=>new Promise((resolve,reject)=>{execFile(command,[name,"--json",JSON.stringify(input)],{maxBuffer:67108864},(error,stdout,stderr)=>{if(error){reject(new Error(String(stderr||"").trim()||error.message));return;}let reply;try{reply=JSON.parse(String(stdout));}catch(parseError){reject(new Error("host function returned invalid JSON: "+parseError.message));return;}if(!reply||reply.ok!==true){reject(new Error((reply&&reply.error)||"host function failed"));return;}resolve(reply.result);});}));for(const [group,definition] of Object.entries(groups)){if(group in globalThis){console.error("agentos: host function group \""+group+"\" was not defined because globalThis."+group+" already exists");continue;}const functions={};for(const [key,name] of definition.functions)functions[key]=call(definition.command,name);Object.defineProperty(globalThis,group,{value:Object.freeze(functions),enumerable:true});}})"#; - - let groups = host_functions - .values() - .filter_map(|collection| { - let command = collection.command_aliases.first()?; - let functions = collection - .callbacks - .keys() - .map(|name| serde_json::json!([host_function_identifier(name), name])) - .collect::>(); - Some(( - host_function_identifier(&collection.name), - serde_json::json!({ "command": command, "functions": functions }), - )) - }) - .collect::>(); - if groups.is_empty() { - return String::new(); - } - format!("{DEFINE_GLOBALS}({});", serde_json::Value::Object(groups)) -} - fn semantic_result_path() -> String { static NEXT_RESULT_ID: AtomicU64 = AtomicU64::new(1); let nonce = NEXT_RESULT_ID.fetch_add(1, Ordering::Relaxed); @@ -1331,26 +1278,6 @@ where "retainEvents requires a context or spawned language process", )); } - // Inline JavaScript and TypeScript run as `node -e `. Give them the - // VM's registered host functions as globals. - if operation.retained_language == Some(RetainedExecutionLanguage::JavaScript) - && operation.args.first().map(String::as_str) == Some("-e") - { - let prefix = self - .vms - .get(&vm_id) - .map(|vm| inline_host_functions_prefix(&vm.host_functions)) - .unwrap_or_default(); - if !prefix.is_empty() { - if let Some(source) = operation.args.get_mut(1) { - source.insert_str(0, &prefix); - } - if let Some(source) = operation.retained_source.as_mut() { - source.insert_str(0, &prefix); - } - } - } - if !context_execution { // Ephemeral calls must not park a language interpreter after the // operation completes. @@ -3107,62 +3034,3 @@ fn execution_limit_error(limit: ExecutionLimit) -> ExecutionErrorData { details: None, } } - -#[cfg(test)] -mod inline_host_functions_prefix_tests { - use super::*; - - fn collection(name: &str, callbacks: &[&str]) -> RegisterHostCallbacksRequest { - RegisterHostCallbacksRequest { - name: name.to_string(), - description: String::new(), - command_aliases: vec![format!("agentos-{name}")], - registry_command_aliases: vec![String::from("agentos")], - callbacks: callbacks - .iter() - .map(|callback| { - ( - callback.to_string(), - RegisteredHostCallbackDefinition { - description: String::new(), - input_schema: String::from("{}"), - timeout_ms: None, - examples: Vec::new(), - }, - ) - }) - .collect(), - } - } - - #[test] - fn converts_kebab_case_names_to_identifiers() { - assert_eq!(host_function_identifier("orders"), "orders"); - assert_eq!(host_function_identifier("order-store"), "orderStore"); - assert_eq!( - host_function_identifier("list-open-orders"), - "listOpenOrders" - ); - } - - #[test] - fn is_empty_without_host_functions() { - assert_eq!(inline_host_functions_prefix(&BTreeMap::new()), ""); - } - - #[test] - fn defines_one_group_per_collection_on_a_single_line() { - let host_functions = BTreeMap::from([( - String::from("order-store"), - collection("order-store", &["list-orders"]), - )]); - let prefix = inline_host_functions_prefix(&host_functions); - assert!( - !prefix.contains('\n'), - "prefix must not shift guest line numbers" - ); - assert!(prefix.ends_with( - r#"({"orderStore":{"command":"agentos-order-store","functions":[["listOrders","list-orders"]]}});"# - )); - } -} diff --git a/crates/native-sidecar/tests/builtin_conformance.rs b/crates/native-sidecar/tests/builtin_conformance.rs index f8c3eac190..fcd6eda05e 100644 --- a/crates/native-sidecar/tests/builtin_conformance.rs +++ b/crates/native-sidecar/tests/builtin_conformance.rs @@ -898,7 +898,9 @@ fn http_request_denied_egress_returns_permission_error_impl() { import http from "node:http"; const result = await new Promise((resolve) => { - const req = http.get("http://127.0.0.1:9/denied", (res) => { + // A scalar network denial keeps VM-local loopback, so probe an external + // address that crosses the VM boundary. + const req = http.get("http://203.0.113.10:9/denied", (res) => { res.resume(); resolve({ statusCode: res.statusCode ?? null, diff --git a/crates/native-sidecar/tests/python.rs b/crates/native-sidecar/tests/python.rs index 5a7a34ccdf..ab3295304a 100644 --- a/crates/native-sidecar/tests/python.rs +++ b/crates/native-sidecar/tests/python.rs @@ -2862,7 +2862,14 @@ fn python_runtime_micropip_install_respects_network_permissions() { )]), PermissionsPolicy { fs: wire_permissions_allow_all().fs, - network: Some(PatternPermissionScope::PermissionMode(PermissionMode::Deny)), + // A scalar network denial keeps VM-local loopback, and the exempt port + // would let it reach the host server, so deny every operation. + network: Some(PatternPermissionScope::PatternPermissionRuleSet( + PatternPermissionRuleSet { + default: Some(PermissionMode::Deny), + rules: Vec::new(), + }, + )), child_process: wire_permissions_allow_all().child_process, process: wire_permissions_allow_all().process, env: wire_permissions_allow_all().env, diff --git a/docs/content/docs/host-functions.mdx b/docs/content/docs/host-functions.mdx index 819abe25e7..74546a67cf 100644 --- a/docs/content/docs/host-functions.mdx +++ b/docs/content/docs/host-functions.mdx @@ -4,7 +4,7 @@ description: "Expose trusted host functions to guest programs in agentOS VMs as skill: true --- -Host functions expose JavaScript functions from your process to code running inside the VM. They run as in-process callbacks, so they are available in the library only, not on hosted VMs. Each function is defined with a Zod input schema and becomes an auto-generated CLI command at `/bin/agentos-{name}`, plus an async function that inline JavaScript can call directly. +Host functions expose JavaScript functions from your process to code running inside the VM. They run as in-process callbacks, so they are available in the library only, not on hosted VMs. Each function is defined with a Zod input schema and becomes an auto-generated CLI command at `/bin/agentos-{name}`, plus an async function that guest JavaScript can call directly. ## Getting started @@ -66,9 +66,13 @@ Missing required flag: --city ## Call host functions from JavaScript -Inline JavaScript and TypeScript get each collection as a frozen global, and each +Every guest Node process gets each collection as a frozen global, and each host function as an async function, so generated code calls your tools like any -other API. Keys written in camelCase become kebab-case commands and camelCase guest +other API. This covers inline `vm.javascript` and `vm.typescript` executions, +scripts started with `vm.process.spawn("node", ...)` or `vm.process.exec`, +`node` run from the guest shell, and child processes of guest Node. The globals +are defined before the entry script and its imports run, so a module can use +them at its top level. Keys written in camelCase become kebab-case commands and camelCase guest identifiers, so `orderStore.listOrders` is `agentos-order-store list-orders` on the CLI and `orderStore.listOrders(input)` in guest JavaScript. diff --git a/docs/content/docs/javascript.mdx b/docs/content/docs/javascript.mdx index 4444772178..aac660617f 100644 --- a/docs/content/docs/javascript.mdx +++ b/docs/content/docs/javascript.mdx @@ -192,7 +192,9 @@ See [Filesystem](/agentos/docs/filesystem), [Processes & Shells](/agentos/docs/p ## Host functions Guest JavaScript calls [host functions](/agentos/docs/host-functions) as async globals, so host -credentials stay outside the VM. Host functions are library-only. +credentials stay outside the VM. The globals exist in every guest Node process, +whether it runs inline code, a spawned script, `node` from the shell, or a child +process. Host functions are library-only. diff --git a/docs/content/docs/networking.mdx b/docs/content/docs/networking.mdx index c8feb02ab2..f462e7adfa 100644 --- a/docs/content/docs/networking.mdx +++ b/docs/content/docs/networking.mdx @@ -77,6 +77,7 @@ browsers, route requests through your application and forward them with Guest listeners and loopback traffic stay inside the VM. Outbound access is limited to the sidecar's default allowlist unless the `network` -[permission](/agentos/docs/permissions) scope allows more. Requests from the -host and previews enter the VM from the host side, so they are subject to -loopback confinement but not to the guest egress policy. +[permission](/agentos/docs/permissions) scope allows more. `network: "deny"` +removes that allowlist and keeps listeners and loopback working. Requests from +the host and previews enter the VM over VM-local loopback, so they are subject +to loopback confinement, not to the guest egress allowlist. diff --git a/docs/content/docs/permissions.mdx b/docs/content/docs/permissions.mdx index 23d22ec30e..8eb0a86de9 100644 --- a/docs/content/docs/permissions.mdx +++ b/docs/content/docs/permissions.mdx @@ -20,6 +20,44 @@ processes, environment, listeners, and loopback networking work, and outbound network access is limited to the sidecar's default allowlist. Library host functions are governed by the `hostFunction` scope. +## Network + +`network: "deny"` denies traffic that crosses the VM boundary and keeps +networking inside the VM working, like a container with no published ports. + +| Traffic | `network: "deny"` | +| --- | --- | +| Guest `listen()` on TCP or Unix sockets | Allowed | +| Guest connections to its own listeners over `127.0.0.1`, `localhost`, `::1`, or Unix sockets | Allowed | +| Host requests to guest servers with `fetch`, `httpRequest`, or previews | Allowed | +| DNS lookups | Denied | +| TCP connections and HTTP requests to any non-loopback address | Denied | +| The default allowlist of model provider endpoints | Denied | + +Guest listeners are never bound on the host, and guest loopback never reaches a +host loopback service unless that host port is listed in `loopbackExemptPorts`. +`network: "deny"` does not change either rule. + +`"deny"` is shorthand for this rule set. A rule set replaces the whole network +scope, so a custom rule set that should keep listeners and loopback working +must include these rules. Omit them to deny VM-local networking too. + +```json +{ + "network": { + "default": "deny", + "rules": [ + { "mode": "allow", "operations": ["listen"], "patterns": ["tcp://**", "unix:**"] }, + { + "mode": "allow", + "operations": ["http"], + "patterns": ["tcp://127.0.0.1:*", "tcp://localhost:*", "tcp://::1:*", "unix:**"] + } + ] + } +} +``` + See the [Security Model](/agentos/docs/security-model) for the trust boundary these policies enforce. diff --git a/examples/embedded/host-functions-javascript.ts b/examples/embedded/host-functions-javascript.ts index 71cbe9e42a..8c38bb64f3 100644 --- a/examples/embedded/host-functions-javascript.ts +++ b/examples/embedded/host-functions-javascript.ts @@ -31,6 +31,18 @@ try { { output: { capture: "all" } }, ); console.log(result.outcome === "succeeded" ? result.stdout : result.error); + + // Every guest Node process gets the same globals, including scripts started + // with process.spawn(), from the shell, or by another guest process. + await vm.writeFile( + "/app/report.mjs", + `const forecast = await weather.forecast({ city: "Tokyo" }); +console.log(forecast);`, + ); + const report = await vm.process.exec("node /app/report.mjs", { + output: { capture: "all" }, + }); + console.log(report.outcome === "succeeded" ? report.stdout : report.error); } finally { await vm.dispose(); } diff --git a/examples/js-networking/src/index.ts b/examples/js-networking/src/index.ts index cb4be484ec..b1ea5f1e8e 100644 --- a/examples/js-networking/src/index.ts +++ b/examples/js-networking/src/index.ts @@ -7,7 +7,9 @@ * 1. With network "allow", the guest starts a loopback HTTP server inside the * VM and fetches it - the request and response stay entirely within the * kernel socket table (hermetic, no real host network). - * 2. With an explicit network deny policy, the same fetch is blocked. + * 2. With network "deny", the same VM-local fetch still works, because + * listeners and loopback never leave the VM, but external egress is + * blocked. * 3. Host loopback is separate from VM loopback: even with network "allow", * a guest can reach a host loopback service only when the host port is in * loopbackExemptPorts. @@ -69,25 +71,34 @@ try { } // docs:end vm-network -// 2. Network denied explicitly. Supplying a partial policy denies omitted -// scopes too, so name the runtime capabilities needed to launch the program. +// 2. Network denied. A scalar deny blocks traffic that crosses the VM +// boundary, so the VM-local server still works while external egress fails. +const EGRESS_GUEST = ` +try { + await fetch("http://example.com/"); + console.log("unexpected: external fetch succeeded"); +} catch (error) { + console.log("external fetch:", error.cause?.code || error.code || error.name); +} +`; const denied = await AgentOs.create({ - permissions: { - fs: "allow", - network: "deny", - childProcess: "allow", - process: "allow", - env: "allow", - }, + permissions: { network: "deny" }, }); try { - const result = await denied.javascript.execute(GUEST, { + const local = await denied.javascript.execute(GUEST, { + output: { capture: "all" }, + }); + console.log("[network denied] VM-local exitCode:", local.exitCode); + console.log( + "[network denied] VM-local stdout:", + JSON.stringify(local.stdout?.trim()), + ); + const external = await denied.javascript.execute(EGRESS_GUEST, { output: { capture: "all" }, }); - console.log("[network denied] exitCode:", result.exitCode); console.log( - "[network denied] stderr:", - JSON.stringify(result.stderr?.trim().split("\n")[0]), + "[network denied] egress stdout:", + JSON.stringify(external.stdout?.trim()), ); } finally { await denied.dispose(); diff --git a/packages/core/package.json b/packages/core/package.json index cd685e92f1..b5a4e873d4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,7 +54,7 @@ "check-types": "tsc --noEmit && tsc -p tsconfig.type-tests.json", "build": "tsc && node scripts/stage-default-software.mjs", "test": "vitest run --exclude '**/*.nightly.test.ts' --reporter=verbose", - "test:unit": "vitest run tests/agentos-package.test.ts tests/allowed-node-builtins.test.ts tests/host-functions-zod.test.ts tests/host-functions.test.ts tests/host-function-command-exec.test.ts tests/host-function-permissions.test.ts tests/cron-manager.test.ts tests/code-mode-execution.test.ts tests/cron-timer-driver.test.ts tests/exec-timeout.test.ts tests/execution-abort.test.ts tests/generated-protocol.test.ts tests/leak-agent-os-processes.test.ts tests/leak-rpc-client.test.ts tests/mount-descriptors.test.ts tests/mount-reconfigure.test.ts tests/options-schema.test.ts tests/output-replay.test.ts tests/public-api-exports.test.ts tests/root-filesystem-descriptors.test.ts tests/runtime-compat-mount.test.ts tests/sidecar-client.test.ts tests/sidecar-exit.test.ts tests/sidecar-permission-descriptors.test.ts tests/software-install.test.ts tests/wasm-permission-tiers.test.ts --fileParallelism=false", + "test:unit": "vitest run tests/agentos-package.test.ts tests/allowed-node-builtins.test.ts tests/host-functions-zod.test.ts tests/host-functions.test.ts tests/host-function-command-exec.test.ts tests/host-function-permissions.test.ts tests/host-function-globals.test.ts tests/cron-manager.test.ts tests/code-mode-execution.test.ts tests/cron-timer-driver.test.ts tests/exec-timeout.test.ts tests/execution-abort.test.ts tests/generated-protocol.test.ts tests/leak-agent-os-processes.test.ts tests/leak-rpc-client.test.ts tests/mount-descriptors.test.ts tests/mount-reconfigure.test.ts tests/network-deny-vm-local.test.ts tests/options-schema.test.ts tests/output-replay.test.ts tests/public-api-exports.test.ts tests/root-filesystem-descriptors.test.ts tests/runtime-compat-mount.test.ts tests/sidecar-client.test.ts tests/sidecar-exit.test.ts tests/sidecar-permission-descriptors.test.ts tests/software-install.test.ts tests/wasm-permission-tiers.test.ts --fileParallelism=false", "test:pr": "pnpm test:unit", "test:nightly": "vitest run tests/*.nightly.test.ts --reporter=verbose --passWithNoTests" }, diff --git a/packages/core/tests/host-function-globals.nightly.test.ts b/packages/core/tests/host-function-globals.nightly.test.ts new file mode 100644 index 0000000000..256d500007 --- /dev/null +++ b/packages/core/tests/host-function-globals.nightly.test.ts @@ -0,0 +1,68 @@ +// Nightly: launches Node through the guest shell, which needs the default +// software bundle (`sh` from coreutils). +import { afterEach, describe, expect, test } from "vitest"; +import { z } from "zod"; +import { AgentOs } from "../src/index.js"; +import { ALLOW_ALL_VM_PERMISSIONS } from "./helpers/permissions.js"; + +const orderStore = { + listOrders: { + inputSchema: z.object({ status: z.string() }), + execute: ({ status }: { status: string }) => ({ + orders: [{ id: "order-1", status }], + }), + }, +}; + +const EXPECTED_RESULT = { orders: [{ id: "order-1", status: "open" }] }; + +function lastJsonLine(stdout: string): unknown { + const lines = stdout.trim().split("\n"); + return JSON.parse(lines[lines.length - 1] ?? ""); +} + +describe("host-function globals in shell-launched Node processes", () => { + let vm: AgentOs | undefined; + + afterEach(async () => { + await vm?.dispose(); + vm = undefined; + }); + + test("node run from the guest shell can call host functions", async () => { + vm = await AgentOs.create({ + permissions: ALLOW_ALL_VM_PERMISSIONS, + hostFunctions: { orderStore }, + }); + await vm.writeFile( + "/app/main.mjs", + `const result = await orderStore.listOrders({ status: "open" }); +console.log(JSON.stringify(result));`, + ); + + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const { pid } = await vm.process.spawn("sh", ["-c", "node /app/main.mjs"], { + onStdout: (chunk) => { + stdout += decoder.decode(chunk); + }, + onStderr: (chunk) => { + stderr += decoder.decode(chunk); + }, + }); + const exit = await vm.process.wait(pid); + expect({ exitCode: exit.exitCode, stderr }, stdout).toEqual({ + exitCode: 0, + stderr: "", + }); + expect(lastJsonLine(stdout)).toEqual(EXPECTED_RESULT); + + const exec = await vm.process.exec("node /app/main.mjs", { + output: { capture: "all" }, + }); + expect(exec.outcome, JSON.stringify(exec)).toBe("succeeded"); + if (exec.outcome !== "succeeded") return; + expect(lastJsonLine(exec.stdout ?? "")).toEqual(EXPECTED_RESULT); + }, 120_000); +}); diff --git a/packages/core/tests/host-function-globals.test.ts b/packages/core/tests/host-function-globals.test.ts new file mode 100644 index 0000000000..80edefc4c9 --- /dev/null +++ b/packages/core/tests/host-function-globals.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { z } from "zod"; +import { AgentOs } from "../src/index.js"; +import { ALLOW_ALL_VM_PERMISSIONS } from "./helpers/permissions.js"; + +// Every guest Node process in a VM with host functions gets one frozen global +// per collection, not only inline `vm.javascript.*` executions. These tests +// launch Node the other ways a guest can: a direct spawn and a child process +// of guest Node. The shell launch paths need the default software bundle, so +// they live in host-function-globals.nightly.test.ts. + +const orderStore = { + listOrders: { + inputSchema: z.object({ status: z.string() }), + execute: ({ status }: { status: string }) => ({ + orders: [{ id: "order-1", status }], + }), + }, +}; + +const EXPECTED_RESULT = { orders: [{ id: "order-1", status: "open" }] }; + +const MAIN_SCRIPT = ` +const result = await orderStore.listOrders({ status: "open" }); +console.log(JSON.stringify(result)); +`; + +async function runNode( + vm: AgentOs, + command: string, + args: string[], +): Promise<{ exitCode?: number; stdout: string; stderr: string }> { + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const { pid } = await vm.process.spawn(command, args, { + onStdout: (chunk) => { + stdout += decoder.decode(chunk); + }, + onStderr: (chunk) => { + stderr += decoder.decode(chunk); + }, + }); + const exit = await vm.process.wait(pid); + return { exitCode: exit.exitCode, stdout, stderr }; +} + +function lastJsonLine(stdout: string): unknown { + const lines = stdout.trim().split("\n"); + return JSON.parse(lines[lines.length - 1] ?? ""); +} + +describe("host-function globals in guest Node processes", () => { + let vm: AgentOs | undefined; + + afterEach(async () => { + await vm?.dispose(); + vm = undefined; + }); + + test("spawned and child Node processes can call host functions", async () => { + vm = await AgentOs.create({ + permissions: ALLOW_ALL_VM_PERMISSIONS, + hostFunctions: { orderStore }, + }); + await vm.writeFile("/app/main.mjs", MAIN_SCRIPT); + await vm.writeFile( + "/app/main.cjs", + `orderStore.listOrders({ status: "open" }).then((result) => { + console.log(JSON.stringify(result)); + });`, + ); + // A static import evaluates before the importing module's body, so this + // also covers modules that use the global at their top level. + await vm.writeFile( + "/app/orders.mjs", + `export const result = await orderStore.listOrders({ status: "open" });`, + ); + await vm.writeFile( + "/app/import.mjs", + `import { result } from "./orders.mjs"; +console.log(JSON.stringify(result));`, + ); + await vm.writeFile( + "/app/parent.mjs", + `import { execFile } from "node:child_process"; +execFile("node", ["/app/main.mjs"], (error, stdout, stderr) => { + if (error) { + console.error(stderr || error.message); + process.exit(1); + } + process.stdout.write(stdout); +});`, + ); + + const cases: Array<[string, string, string[]]> = [ + ["process.spawn ESM", "node", ["/app/main.mjs"]], + ["process.spawn CommonJS", "node", ["/app/main.cjs"]], + ["process.spawn static import", "node", ["/app/import.mjs"]], + ["guest child_process", "node", ["/app/parent.mjs"]], + ]; + for (const [label, command, args] of cases) { + const result = await runNode(vm, command, args); + expect( + { label, exitCode: result.exitCode, stderr: result.stderr }, + `stdout: ${result.stdout}`, + ).toEqual({ label, exitCode: 0, stderr: "" }); + expect(lastJsonLine(result.stdout), label).toEqual(EXPECTED_RESULT); + } + + // The generated CLI keeps working alongside the globals. + const cli = await runNode(vm, "agentos-order-store", [ + "list-orders", + "--status", + "open", + ]); + expect(cli.exitCode, cli.stderr).toBe(0); + expect(JSON.parse(cli.stdout)).toEqual({ + ok: true, + result: EXPECTED_RESULT, + }); + }, 120_000); + + test("inline executions keep the globals across a retained context", async () => { + vm = await AgentOs.create({ + permissions: ALLOW_ALL_VM_PERMISSIONS, + hostFunctions: { orderStore }, + }); + await vm.createContext("orders"); + for (let run = 0; run < 2; run++) { + const result = await vm.javascript.evaluate( + `orderStore.listOrders({ status: "open" })`, + { contextId: "orders" }, + ); + expect(result, `run ${run}`).toMatchObject({ + exitCode: 0, + value: EXPECTED_RESULT, + }); + expect(JSON.stringify(result), `run ${run}`).not.toContain( + "was not defined", + ); + } + }, 60_000); + + test("the hostFunction permission still applies to the globals", async () => { + vm = await AgentOs.create({ + permissions: { + ...ALLOW_ALL_VM_PERMISSIONS, + hostFunction: { default: "deny", rules: [] }, + }, + hostFunctions: { orderStore }, + }); + await vm.writeFile( + "/app/denied.mjs", + `try { + await orderStore.listOrders({ status: "open" }); + console.log("ALLOWED"); +} catch (error) { + console.log("DENIED " + error.message); +}`, + ); + + const result = await runNode(vm, "node", ["/app/denied.mjs"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("DENIED"); + expect(result.stdout).toContain("hostFunction.invoke"); + expect(result.stdout).toContain("order-store:list-orders"); + }, 60_000); + + test("no globals are defined without host functions", async () => { + vm = await AgentOs.create({ permissions: ALLOW_ALL_VM_PERMISSIONS }); + await vm.writeFile( + "/app/types.mjs", + `console.log(JSON.stringify(typeof globalThis.orderStore));`, + ); + + const result = await runNode(vm, "node", ["/app/types.mjs"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(lastJsonLine(result.stdout)).toBe("undefined"); + }, 60_000); + + test("a collection named like an existing global is skipped with a warning", async () => { + vm = await AgentOs.create({ + permissions: ALLOW_ALL_VM_PERMISSIONS, + hostFunctions: { + process: { + status: { + inputSchema: z.object({}), + execute: () => ({ ok: true }), + }, + }, + orderStore, + }, + }); + await vm.writeFile( + "/app/collision.mjs", + `console.log(JSON.stringify({ + processStatus: typeof process.status, + cwd: typeof process.cwd, + orderStore: typeof orderStore.listOrders, +}));`, + ); + + const result = await runNode(vm, "node", ["/app/collision.mjs"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toContain( + 'agentos: host function group "process" was not defined because globalThis.process already exists', + ); + expect(lastJsonLine(result.stdout)).toEqual({ + processStatus: "undefined", + cwd: "function", + orderStore: "function", + }); + }, 60_000); +}); diff --git a/packages/core/tests/network-deny-vm-local.test.ts b/packages/core/tests/network-deny-vm-local.test.ts new file mode 100644 index 0000000000..5221f5d85e --- /dev/null +++ b/packages/core/tests/network-deny-vm-local.test.ts @@ -0,0 +1,154 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, test } from "vitest"; +import { AgentOs } from "../src/index.js"; + +// A scalar `network: "deny"` denies traffic that crosses the VM boundary. Like +// the default policy, it keeps guest listeners and VM-local loopback working, +// so a VM behaves like a container with no published ports. + +const SERVER_SCRIPT = ` +import http from "node:http"; +const server = http.createServer((req, res) => res.end("guest-ok:" + req.url)); +server.on("error", (error) => { + console.log("LISTEN_ERROR " + error.code + " " + error.message); + process.exit(3); +}); +server.listen(3000, "127.0.0.1", () => console.log("LISTENING")); +`; + +const PROBE_SCRIPT = ` +const probe = async (url) => { + try { + const response = await fetch(url); + return { status: response.status, body: await response.text() }; + } catch (error) { + const cause = error.cause ?? error; + return { error: String(cause.code ?? cause.name), message: String(cause.message) }; + } +}; +const [vmLocal, external, hostLoopback] = await Promise.all([ + probe("http://127.0.0.1:3000/self"), + probe("http://example.com/"), + probe("http://127.0.0.1:" + process.argv[2] + "/host"), +]); +console.log(JSON.stringify({ vmLocal, external, hostLoopback })); +`; + +async function startGuestServer(vm: AgentOs): Promise { + const decoder = new TextDecoder(); + let stdout = ""; + let listening!: () => void; + let failed!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + listening = resolve; + failed = reject; + }); + const { pid } = await vm.process.spawn("node", ["/app/server.mjs"], { + onStdout: (chunk) => { + stdout += decoder.decode(chunk); + if (stdout.includes("LISTENING")) listening(); + if (stdout.includes("LISTEN_ERROR")) failed(new Error(stdout)); + }, + }); + await ready; + return pid; +} + +async function runProbe(vm: AgentOs, hostPort: number) { + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const { pid } = await vm.process.spawn( + "node", + ["/app/probe.mjs", String(hostPort)], + { + onStdout: (chunk) => { + stdout += decoder.decode(chunk); + }, + onStderr: (chunk) => { + stderr += decoder.decode(chunk); + }, + }, + ); + const exit = await vm.process.wait(pid); + expect(exit.exitCode, stderr).toBe(0); + return JSON.parse(stdout.trim().split("\n").at(-1) ?? ""); +} + +describe('scalar network "deny"', () => { + let vm: AgentOs | undefined; + let hostServer: Server | undefined; + let hostRequests = 0; + + afterEach(async () => { + await vm?.dispose(); + vm = undefined; + await new Promise((resolve) => { + if (!hostServer) return resolve(); + hostServer.close(() => resolve()); + }); + hostServer = undefined; + hostRequests = 0; + }); + + async function startHostServer(): Promise { + hostServer = createServer((_req, res) => { + hostRequests += 1; + res.end("host-ok"); + }); + await new Promise((resolve) => { + hostServer?.listen(0, "127.0.0.1", resolve); + }); + return (hostServer.address() as AddressInfo).port; + } + + test("keeps VM-local listeners and loopback while denying egress", async () => { + const hostPort = await startHostServer(); + vm = await AgentOs.create({ permissions: { network: "deny" } }); + await vm.writeFile("/app/server.mjs", SERVER_SCRIPT); + await vm.writeFile("/app/probe.mjs", PROBE_SCRIPT); + const serverPid = await startGuestServer(vm); + + // The host reaches the guest server over VM-local loopback. + const response = await vm.network.httpRequest({ + port: 3000, + path: "/from-host", + }); + expect(response.status).toBe(200); + expect(new TextDecoder().decode(response.body)).toBe("guest-ok:/from-host"); + + const result = await runProbe(vm, hostPort); + // Guest loopback reaches the guest's own listener. + expect(result.vmLocal).toEqual({ status: 200, body: "guest-ok:/self" }); + // External egress is denied by the network policy. + expect(result.external.error).toBe("EACCES"); + // Guest loopback is VM-local, so loopback confinement keeps it from + // reaching host loopback services without an explicit exemption. + expect(result.hostLoopback.error).toBe("EACCES"); + expect(result.hostLoopback.message).toContain("not owned by this VM"); + expect(hostRequests).toBe(0); + + await vm.process.kill(serverPid); + await vm.process.wait(serverPid); + }, 60_000); + + test("an explicit rule set can still deny VM-local networking", async () => { + vm = await AgentOs.create({ + permissions: { network: { default: "deny", rules: [] } }, + }); + await vm.writeFile("/app/server.mjs", SERVER_SCRIPT); + + const decoder = new TextDecoder(); + let stdout = ""; + const { pid } = await vm.process.spawn("node", ["/app/server.mjs"], { + onStdout: (chunk) => { + stdout += decoder.decode(chunk); + }, + }); + const exit = await vm.process.wait(pid); + expect(exit.exitCode, stdout).toBe(3); + expect(stdout).toContain("LISTEN_ERROR EACCES"); + expect(stdout).toContain("network.listen"); + }, 60_000); +}); diff --git a/secure-exec/docs/content/docs/host-functions.mdx b/secure-exec/docs/content/docs/host-functions.mdx index 8b2585b9ce..de61631b99 100644 --- a/secure-exec/docs/content/docs/host-functions.mdx +++ b/secure-exec/docs/content/docs/host-functions.mdx @@ -24,6 +24,8 @@ Arguments from the guest are validated before your code runs. ## Call them from guest code Inside the VM each collection is a global object and each function is async. +The globals exist in every guest Node process: inline code, spawned scripts, +`node` run from the shell, and child processes of guest Node. diff --git a/secure-exec/docs/content/docs/permissions.mdx b/secure-exec/docs/content/docs/permissions.mdx index 10337e3159..0f96f22c72 100644 --- a/secure-exec/docs/content/docs/permissions.mdx +++ b/secure-exec/docs/content/docs/permissions.mdx @@ -26,7 +26,10 @@ scope as it was. ## Allow specific hosts A rule set denies by default and allows matching patterns. A host needs both a -`dns://` pattern to resolve it and a `tcp://` pattern to connect to it. +`dns://` pattern to resolve it and a `tcp://` pattern to connect to it. A rule +set replaces the whole network scope, so add the VM-local `listen` and loopback +rules from the [agentOS permissions reference](/agentos/docs/permissions#network) +if guest code also runs a server. `network: "deny"` keeps them.