From feb7b1ad906046f4cf6ea53a85506f42b1b66e93 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 17 Oct 2026 11:25:55 +0200 Subject: [PATCH] feat: add a no_network annotation for sandboxed jobs (#11844) * feat: add a no_network annotation for sandboxed python, bash, bun and r jobs Co-Authored-By: Claude Opus 5.5 * fix: keep nodejs relative imports and dedicated workers working under no_network Co-Authored-By: Claude Opus 5.5 * fix: bundle bun no_network jobs so relative imports resolve before the run Co-Authored-By: Claude Opus 5.5 * chore: point ee-repo-ref at the runner group no_network fix Co-Authored-By: Claude Opus 5.5 * refactor: state the no_network token rule once Co-Authored-By: Claude Opus 5.5 * feat: refuse no_network jobs whose workspace imports would be fetched at run time Co-Authored-By: Claude Opus 5.5 * fix: let type-only workspace imports through the no_network import check Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to ca4a1959a0d2257d413a57349939053ac3473c6b This commit updates the EE repository reference after PR #895 was merged in windmill-ee-private. Previous ee-repo-ref: 3841923cfd117b6b907ac3e84b26f07bde41f8ed New ee-repo-ref: ca4a1959a0d2257d413a57349939053ac3473c6b Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/parsers/windmill-parser-ts/src/lib.rs | 15 +- .../parsers/windmill-parser-ts/tests/tests.rs | 15 +- backend/windmill-common/src/worker.rs | 19 ++ .../nsjail/run.bash.config.proto | 2 +- .../nsjail/run.bun.config.proto | 2 +- .../nsjail/run.docker.config.proto | 2 +- .../nsjail/run.python3.config.proto | 2 +- .../windmill-worker/nsjail/run.r.config.proto | 2 +- backend/windmill-worker/src/bash_executor.rs | 48 +++-- backend/windmill-worker/src/bun_executor.rs | 62 +++++- backend/windmill-worker/src/docker_v2.rs | 15 +- .../windmill-worker/src/python_executor.rs | 35 +++- backend/windmill-worker/src/r_executor.rs | 47 +++-- backend/windmill-worker/src/worker.rs | 183 ++++++++++++++++++ cli/src/guidance/skills.gen.ts | 2 +- system_prompts/auto-generated/prompts.ts | 2 +- system_prompts/auto-generated/script.md | 2 +- .../skills/write-script-bunnative/SKILL.md | 2 +- system_prompts/languages/bunnative.md | 2 +- typescript-client/client.ts | 8 +- 21 files changed, 407 insertions(+), 62 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f6aca2078d..0afee5834b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -455681e55eb7cdfcfdb7cd79cbae75b333e07943 +ca4a1959a0d2257d413a57349939053ac3473c6b diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index ac75080994..8059851707 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -189,7 +189,20 @@ pub fn parse_expr_for_imports(code: &str, skip_type_only: bool) -> anyhow::Resul /// // Returns: ["f/utils"] /// ``` pub fn parse_relative_imports(code: &str, path: &str) -> anyhow::Result> { - let imports = parse_expr_for_imports(code, false)?; + resolve_relative_imports(code, path, false) +} + +/// [`parse_relative_imports`] without type-only imports, which are erased before the code runs. +pub fn parse_runtime_relative_imports(code: &str, path: &str) -> anyhow::Result> { + resolve_relative_imports(code, path, true) +} + +fn resolve_relative_imports( + code: &str, + path: &str, + skip_type_only: bool, +) -> anyhow::Result> { + let imports = parse_expr_for_imports(code, skip_type_only)?; let script_dir = path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""); let mut resolved: Vec = imports diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index b4abc23488..8b9587d3a2 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -4,7 +4,7 @@ mod tests { use windmill_parser::{Arg, MainArgSignature, ObjectProperty, ObjectType, Typ}; use windmill_parser_ts::{ parse_deno_signature, parse_expr_for_imports, parse_relative_imports, - remove_pinned_import_specifiers, + parse_runtime_relative_imports, remove_pinned_import_specifiers, }; /// The server parses defaults natively, where a non-JSON literal cannot be evaluated @@ -944,6 +944,19 @@ export const label = "pkg@1.2.3"; assert_eq!(result, vec!["f/folder/helper"]); } + #[test] + fn test_runtime_relative_imports_skip_type_only() { + let code = r#" + import type { Shape } from "./types"; + import { helper } from "./helper"; + export async function main(s: Shape) { return helper(s); } + "#; + let result = parse_runtime_relative_imports(code, "f/folder/script").unwrap(); + assert_eq!(result, vec!["f/folder/helper"]); + let all = parse_relative_imports(code, "f/folder/script").unwrap(); + assert_eq!(all, vec!["f/folder/helper", "f/folder/types"]); + } + #[test] fn test_relative_imports_double_dot() { let code = r#" diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 868f7fe29e..41d437c30a 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -1199,6 +1199,7 @@ pub struct RlangAnnotations { pub renv_verbose: bool, pub renv_install_verbose: bool, pub sandbox: bool, + pub no_network: bool, } #[annotations("#")] @@ -1214,6 +1215,7 @@ pub struct PythonAnnotations { pub py314: bool, pub py315: bool, pub sandbox: bool, + pub no_network: bool, } #[annotations("//")] @@ -1324,6 +1326,7 @@ impl SqlAnnotations { pub struct BashAnnotations { pub docker: bool, pub sandbox: bool, + pub no_network: bool, } impl BashAnnotations { @@ -3834,6 +3837,22 @@ mod tests { assert!(combined.prepare); } + #[test] + fn test_no_network_annotation() { + assert!(PythonAnnotations::parse("# sandbox\n# no_network\ndef main(): pass").no_network); + assert!(!PythonAnnotations::parse("# sandbox\ndef main(): pass").no_network); + assert!(RlangAnnotations::parse("#no_network\nmain <- function() {}").no_network); + assert!( + TypeScriptAnnotations::parse("// no_network\nexport function main() {}").no_network + ); + let bash = "# sandbox alpine\n# no_network\necho hi"; + assert!(BashAnnotations::parse(bash).no_network); + assert_eq!( + BashAnnotations::sandbox_image(bash), + Some("alpine".to_string()) + ); + } + #[test] fn test_python_sandbox_annotation_with_other_annotations() { let content = "# no_cache\n# sandbox\ndef main():\n pass"; diff --git a/backend/windmill-worker/nsjail/run.bash.config.proto b/backend/windmill-worker/nsjail/run.bash.config.proto index 899f7caa1e..b89d4a5d91 100644 --- a/backend/windmill-worker/nsjail/run.bash.config.proto +++ b/backend/windmill-worker/nsjail/run.bash.config.proto @@ -9,7 +9,7 @@ disable_rl: true cwd: "/tmp" -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} skip_setsid: true diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index cd1510adc6..98794a74bd 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -11,7 +11,7 @@ mount_proc: true cwd: "/tmp/{LANG}" -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} clone_newcgroup: false diff --git a/backend/windmill-worker/nsjail/run.docker.config.proto b/backend/windmill-worker/nsjail/run.docker.config.proto index a8e327927e..8ba41e947d 100644 --- a/backend/windmill-worker/nsjail/run.docker.config.proto +++ b/backend/windmill-worker/nsjail/run.docker.config.proto @@ -9,7 +9,7 @@ disable_rl: true cwd: {WORKDIR} -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} skip_setsid: true diff --git a/backend/windmill-worker/nsjail/run.python3.config.proto b/backend/windmill-worker/nsjail/run.python3.config.proto index 269ae1f0f8..6e457207f5 100644 --- a/backend/windmill-worker/nsjail/run.python3.config.proto +++ b/backend/windmill-worker/nsjail/run.python3.config.proto @@ -12,7 +12,7 @@ rlimit_nofile: 10000 cwd: "/tmp" -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} skip_setsid: true diff --git a/backend/windmill-worker/nsjail/run.r.config.proto b/backend/windmill-worker/nsjail/run.r.config.proto index a0c4c51cf0..7384a4eb3b 100644 --- a/backend/windmill-worker/nsjail/run.r.config.proto +++ b/backend/windmill-worker/nsjail/run.r.config.proto @@ -8,7 +8,7 @@ disable_rl: true cwd: "/tmp" -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} skip_setsid: true diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 2da8f8d73f..7ec2f4fd33 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -39,15 +39,16 @@ const NSJAIL_CONFIG_RUN_BASH_CONTENT: &str = include_str!("../nsjail/run.bash.co use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ + check_no_network_jailed, common::{ build_args_map, build_command_with_isolation, get_reserved_variables, raw_to_string, read_file, read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::handle_child, - is_sandboxing_enabled, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, - TRACING_PROXY_CA_CERT_PATH, + is_sandboxing_enabled, nsjail_clone_newnet, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, + NSJAIL_PATH, PATH_ENV, TRACING_PROXY_CA_CERT_PATH, }; use windmill_common::client::AuthedClient; use windmill_common::scripts::ScriptLang; @@ -94,6 +95,7 @@ pub async fn handle_bash_job( if let Some(image) = windmill_common::worker::BashAnnotations::sandbox_image(content) { return crate::docker_v2::handle_docker_v2_job( &image, + annotation.no_network, mem_peak, canceled_by, job, @@ -115,6 +117,13 @@ pub async fn handle_bash_job( // `ssh_target` resource instead of on this worker. The OSS build returns a // clear "enterprise feature" error from the stub. if let Some(ssh_path) = windmill_common::worker::BashAnnotations::ssh_target(content) { + if annotation.no_network { + return Err(Error::ExecutionErr( + "Script has the `no_network` annotation, which a remote `#ssh` host cannot \ + honor. Refusing to run it with network." + .to_string(), + )); + } return crate::ssh_executor_oss::handle_ssh_bash_job( &ssh_path, mem_peak, @@ -139,6 +148,19 @@ pub async fn handle_bash_job( )); } + // Init/periodic scripts need full system access without isolation + let is_regular_job = job + .runnable_path + .as_ref() + .map(|x| { + !x.starts_with(INIT_SCRIPT_PATH_PREFIX) && !x.starts_with(PERIODIC_SCRIPT_PATH_PREFIX) + }) + .unwrap_or(true); + + // Use nsjail if globally enabled OR if script has #sandbox annotation + let nsjail = (is_sandboxing_enabled() || annotation.sandbox) && is_regular_job; + check_no_network_jailed(annotation.no_network, nsjail)?; + let mut logs1 = "\n\n--- BASH CODE EXECUTION ---\n".to_string(); if annotation.docker { logs1.push_str("docker mode\n"); @@ -146,6 +168,9 @@ pub async fn handle_bash_job( if annotation.sandbox { logs1.push_str("sandbox mode (nsjail)\n"); } + if annotation.no_network { + logs1.push_str("no network\n"); + } append_logs(&job.id, &job.workspace_id, logs1, &conn).await; write_file(job_dir, "main.sh", &format!("set -e\n{content}"))?; @@ -204,6 +229,7 @@ exit $exit_status let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; reserved_variables.insert("RUST_LOG".to_string(), "info".to_string()); + crate::strip_no_network_token(&mut reserved_variables, annotation.no_network); let args = build_args_map(job, client, conn).await?.map(Json); let job_args = if args.is_some() { @@ -237,18 +263,6 @@ exit $exit_status vec![] }; - // Check if this is a regular job (not init or periodic script) - // Init/periodic scripts need full system access without isolation - let is_regular_job = job - .runnable_path - .as_ref() - .map(|x| { - !x.starts_with(INIT_SCRIPT_PATH_PREFIX) && !x.starts_with(PERIODIC_SCRIPT_PATH_PREFIX) - }) - .unwrap_or(true); - - // Use nsjail if globally enabled OR if script has #sandbox annotation - let nsjail = (is_sandboxing_enabled() || annotation.sandbox) && is_regular_job; let child = if nsjail { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; @@ -258,6 +272,7 @@ exit $exit_status &NSJAIL_CONFIG_RUN_BASH_CONTENT .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{CLONE_NEWNET}", nsjail_clone_newnet(annotation.no_network)) .replace("{SHARED_MOUNT}", shared_mount) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) @@ -281,12 +296,13 @@ exit $exit_status .env_clear() .envs(reserved_variables) .envs( - get_proxy_envs_for_lang( + get_run_proxy_envs( &ScriptLang::Bash, job.kind, &job.id, &job.workspace_id, conn, + annotation.no_network, ) .await?, ) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 201219b674..d8b206440d 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -18,18 +18,20 @@ use windmill_parser_ts::{remove_pinned_import_specifiers, remove_pinned_imports} use windmill_queue::{append_logs, CanceledBy, MiniPulledJob, PrecomputedAgentInfo}; use crate::{ + check_no_network_jailed, common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, script_envars, start_child_process, write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::handle_child, - is_sandboxing_enabled, read_ee_registry_with_workspace_override, BUNFIG_INSTALL_SCOPES, - BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_INSTALL_MIN_RELEASE_AGE, BUN_NO_CACHE, BUN_PATH, - DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, - NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, + is_sandboxing_enabled, nsjail_clone_newnet, read_ee_registry_with_workspace_override, + BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_INSTALL_MIN_RELEASE_AGE, + BUN_NO_CACHE, BUN_PATH, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, NPMRC, + NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, + TRACING_PROXY_CA_CERT_PATH, TZ_ENV, }; use windmill_common::{ cache, @@ -1666,6 +1668,33 @@ pub async fn handle_bun_job( .to_string(), )); } + let jailed = is_sandboxing_enabled() || annotation.sandbox; + // Native scripts take their network away inside the isolate instead. + let jail_no_network = annotation.no_network && !annotation.native; + check_no_network_jailed(jail_no_network, jailed)?; + // Unbundled, Bun's run-time loader fetches workspace imports; Node's are bundled on the host. + if jail_no_network + && codebase.is_none() + && !annotation.nodejs + && (annotation.nobundling || *DISABLE_BUNDLING) + { + let script_path = crate::common::use_flow_root_path(job.runnable_path()); + let script_dir = script_path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""); + let imports = std::iter::once((script_path.clone(), inner_content.as_str())) + .chain(modules.iter().flatten().map(|(key, module)| { + let stem = key.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(key); + (format!("{script_dir}/{stem}"), module.content.as_str()) + })) + .flat_map(|(path, code)| { + windmill_parser_ts::parse_runtime_relative_imports(code, &path).unwrap_or_default() + }); + crate::check_no_network_imports( + imports, + &script_path, + modules.as_ref(), + "Bun without bundling (`//nobundling` or `DISABLE_BUNDLING`)", + )?; + } let (mut has_bundle_cache, cache_logs, local_path, remote_path) = if let (Some(lock), true) = ( maybe_lock.get_lock(), @@ -1916,6 +1945,9 @@ pub async fn handle_bun_job( if annotation.sandbox { init_logs.push_str("sandbox mode (nsjail)\n"); } + if jail_no_network { + init_logs.push_str("no network\n"); + } let write_wrapper_f = async { if !has_bundle_cache && annotation.native { @@ -2232,7 +2264,8 @@ try {{ && !annotation.nobundling && !*DISABLE_BUNDLING && !codebase.is_some() - && (maybe_lock.get_lock().is_some() || annotation.native); + // A `no_network` run step cannot fetch relative imports, so they resolve while bundling. + && (maybe_lock.get_lock().is_some() || annotation.native || jail_no_network); let write_loader_f = async { if build_cache { @@ -2258,7 +2291,13 @@ try {{ build_loader( job_dir, base_internal_url, - &client.token, + // Bun's run step reads this loader (Node's builds on the host), and could hand + // the token out through its result. + if jail_no_network && !annotation.nodejs { + "" + } else { + &client.token + }, &job.workspace_id, job.runnable_path(), if annotation.nodejs { @@ -2274,11 +2313,12 @@ try {{ } }; - let (reserved_variables, _, _) = tokio::try_join!( + let (mut reserved_variables, _, _) = tokio::try_join!( reserved_variables_args_out_f, write_wrapper_f, write_loader_f )?; + crate::strip_no_network_token(&mut reserved_variables, jail_no_network); // For WAC v2, write checkpoint.json before bun runs if is_wac_v2 { @@ -2491,7 +2531,7 @@ try {{ append_logs(&job.id, &job.workspace_id, init_logs, conn).await; //do not cache local dependencies - let child = if is_sandboxing_enabled() || annotation.sandbox { + let child = if jailed { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; @@ -2534,12 +2574,13 @@ try {{ .env_clear() .envs(reserved_variables) .envs( - get_proxy_envs_for_lang( + get_run_proxy_envs( &ScriptLang::Bun, job.kind, &job.id, &job.workspace_id, conn, + jail_no_network, ) .await?, ) @@ -2555,6 +2596,7 @@ try {{ .replace("{LANG}", if annotation.nodejs { "nodejs" } else { "bun" }) .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{CLONE_NEWNET}", nsjail_clone_newnet(jail_no_network)) .replace("{UIDGIDMAP}", if *DISABLE_NUSER { "" } else { "uidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}\n\ngidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}" }) diff --git a/backend/windmill-worker/src/docker_v2.rs b/backend/windmill-worker/src/docker_v2.rs index 3f47ab6efc..1b02129696 100644 --- a/backend/windmill-worker/src/docker_v2.rs +++ b/backend/windmill-worker/src/docker_v2.rs @@ -36,9 +36,9 @@ use crate::{ render_envars, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_run_proxy_envs, handle_child::handle_child, - DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB, + nsjail_clone_newnet, DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB, SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, SANDBOX_REGISTRY_AUTH, }; @@ -642,6 +642,7 @@ async fn generate_rootfs_mounts(rootfs: &str) -> Result { #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_docker_v2_job( image: &str, + no_network: bool, mem_peak: &mut i32, canceled_by: &mut Option, job: &MiniPulledJob, @@ -671,7 +672,10 @@ pub async fn handle_docker_v2_job( append_logs( &job.id, &job.workspace_id, - format!("\n\n--- SANDBOXED CONTAINER (nsjail) ---\nextracting image {image}...\n"), + format!( + "\n\n--- SANDBOXED CONTAINER (nsjail) ---\n{}extracting image {image}...\n", + if no_network { "no network\n" } else { "" } + ), conn, ) .await; @@ -781,6 +785,7 @@ pub async fn handle_docker_v2_job( &NSJAIL_CONFIG_RUN_DOCKER_CONTENT .replace("{TIMEOUT}", &nsjail_timeout) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{CLONE_NEWNET}", nsjail_clone_newnet(no_network)) // proto_str-quoted: WorkingDir is image-controlled, must not break out // of the `cwd:` string and inject nsjail directives. .replace("{WORKDIR}", &proto_str(working_dir)) @@ -814,13 +819,15 @@ pub async fn handle_docker_v2_job( "BASE_INTERNAL_URL".to_string(), base_internal_url.to_string(), ); + crate::strip_no_network_token(&mut reserved_variables, no_network); - let proxy_envs = get_proxy_envs_for_lang( + let proxy_envs = get_run_proxy_envs( &ScriptLang::Bash, job.kind, &job.id, &job.workspace_id, conn, + no_network, ) .await?; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index e7ddd91531..77874653a0 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -149,15 +149,16 @@ use crate::global_cache::pull_from_tar; use windmill_object_store::get_cache_object_store; use crate::{ + check_no_network_jailed, common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file, read_result, render_nsjail_rlimit_as, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::handle_child, - is_sandboxing_enabled, read_ee_registry, + is_sandboxing_enabled, nsjail_clone_newnet, read_ee_registry, worker_utils::ping_job_status, PyV, DISABLE_NUSER, HOME_ENV, INDEX_CERT, NATIVE_CERT, NSJAIL_AVAILABLE, NSJAIL_PATH, NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, @@ -775,6 +776,8 @@ pub async fn handle_python_job( .to_string(), )); } + let jailed = is_sandboxing_enabled() || annotations.sandbox; + check_no_network_jailed(annotations.no_network, jailed)?; // Preview jobs may carry _TEMP_SCRIPT_REFS so relative imports resolve from // not-yet-deployed local content uploaded to raw_script_temp. Gated on @@ -792,6 +795,19 @@ pub async fn handle_python_job( }; let module_sources = windmill_parser_py_imports::module_sources(&script_path, modules.as_ref()); + if annotations.no_network { + let imports = std::iter::once((script_path.as_str(), inner_content.as_str())) + .chain( + module_sources + .iter() + .flatten() + .map(|(path, code)| (path.as_str(), code.as_str())), + ) + .flat_map(|(path, code)| { + windmill_parser_py_imports::parse_relative_imports(code, path).unwrap_or_default() + }); + crate::check_no_network_imports(imports, &script_path, modules.as_ref(), "Python")?; + } let (py_version, mut additional_python_paths) = handle_python_deps_with_modules( job_dir, requirements_o, @@ -839,6 +855,9 @@ pub async fn handle_python_job( if annotations.sandbox { logs.push_str("sandbox mode (nsjail)\n"); } + if annotations.no_network { + logs.push_str("no network\n"); + } append_logs(&job.id, &job.workspace_id, logs, conn).await; } let ( @@ -1080,6 +1099,7 @@ except BaseException as e: let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; + crate::strip_no_network_token(&mut reserved_variables, annotations.no_network); // Add /tmp/windmill/cache/python_x_y_z/global-site-packages to PYTHONPATH. // Usefull if certain wheels needs to be preinstalled before execution. @@ -1112,7 +1132,7 @@ except BaseException as e: #[cfg(windows)] let additional_python_paths_folders = additional_python_paths_folders.replace(":", ";"); - if is_sandboxing_enabled() || annotations.sandbox { + if jailed { let shared_deps = additional_python_paths .into_iter() .map(|pp| { @@ -1141,6 +1161,10 @@ mount {{ .replace("{JOB_DIR}", job_dir) .replace("{PY_INSTALL_DIR}", &*PY_INSTALL_DIR) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{CLONE_NEWNET}", + nsjail_clone_newnet(annotations.no_network), + ) .replace("{SHARED_MOUNT}", shared_mount) .replace("{SHARED_DEPENDENCIES}", shared_deps.as_str()) .replace("{GLOBAL_SITE_PACKAGES}", &global_site_packages_path) @@ -1167,7 +1191,7 @@ mount {{ job.id ); - let child = if is_sandboxing_enabled() || annotations.sandbox { + let child = if jailed { let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) @@ -1175,12 +1199,13 @@ mount {{ // inject PYTHONPATH here - for some reason I had to do it in nsjail conf .envs(reserved_variables) .envs( - get_proxy_envs_for_lang( + get_run_proxy_envs( &ScriptLang::Python3, job.kind, &job.id, &job.workspace_id, conn, + annotations.no_network, ) .await?, ) diff --git a/backend/windmill-worker/src/r_executor.rs b/backend/windmill-worker/src/r_executor.rs index d273d08785..214cf35525 100644 --- a/backend/windmill-worker/src/r_executor.rs +++ b/backend/windmill-worker/src/r_executor.rs @@ -18,14 +18,15 @@ use windmill_parser_r::{parse_r_requirements, parse_r_signature}; use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ + check_no_network_jailed, common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_tmp_mount_block, script_envars, script_overrides, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_tmp_mount_block, script_envars, script_overrides, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::{self}, - is_sandboxing_enabled, + is_sandboxing_enabled, nsjail_clone_newnet, universal_pkg_installer::{ par_install_language_dependencies_seq, DependencyGraph, InstallDeps, RequiredDependency, }, @@ -86,6 +87,8 @@ pub async fn handle_r_job<'a>( .to_string(), )); } + let jailed = !cfg!(windows) && (is_sandboxing_enabled() || annotation.sandbox); + check_no_network_jailed(annotation.no_network, jailed)?; // --- Prepare --- { @@ -120,7 +123,13 @@ pub async fn handle_r_job<'a>( }; // --- Execute --- { - run(&mut args, lib_path.as_deref(), annotation.sandbox).await?; + run( + &mut args, + lib_path.as_deref(), + jailed, + annotation.no_network, + ) + .await?; } // --- Retrieve results --- { @@ -593,17 +602,21 @@ async fn run<'a>( .. }: &mut JobHandlerInput<'a>, lib_path: Option<&str>, - sandbox: bool, + nsjail: bool, + no_network: bool, ) -> Result<(), Error> { - let reserved_variables = + let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?; + crate::strip_no_network_token(&mut reserved_variables, no_network); - let nsjail = !cfg!(windows) && (is_sandboxing_enabled() || sandbox); let child = if nsjail { append_logs( &job.id, &job.workspace_id, - "\n--- R CODE EXECUTION (nsjail) ---\n".to_string(), + format!( + "\n--- R CODE EXECUTION (nsjail) ---\n{}", + if no_network { "no network\n" } else { "" } + ), conn, ) .await; @@ -614,14 +627,19 @@ async fn run<'a>( .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .envs(&reserved_variables) - .envs(R_PROXY_ENVS.clone()) + .envs(if no_network { + vec![] + } else { + R_PROXY_ENVS.clone() + }) .envs( - get_proxy_envs_for_lang( + get_run_proxy_envs( &ScriptLang::Rlang, job.kind, &job.id, &job.workspace_id, conn, + no_network, ) .await?, ); @@ -651,9 +669,14 @@ async fn run<'a>( &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{CLONE_NEWNET}", nsjail_clone_newnet(no_network)) .replace( "{ENVARS}", - &script_envars(&envs, &cmd, &script_overrides(&["PATH", "BASE_INTERNAL_URL"], &reserved_variables)), + &script_envars( + &envs, + &cmd, + &script_overrides(&["PATH", "BASE_INTERNAL_URL"], &reserved_variables), + ), ), )?; start_child_process(cmd, NSJAIL_PATH.as_str(), false).await? diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 7ad7558c98..feb5502801 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1022,6 +1022,189 @@ pub fn is_sandboxing_enabled() -> bool { } } +/// A `no_network` job loses its network only through the fresh network namespace its +/// nsjail run step is given, so a run step that would not be jailed is refused rather +/// than run with the worker's network. `jailed` is the executor's own decision to jail. +pub fn check_no_network_jailed( + no_network: bool, + jailed: bool, +) -> windmill_common::error::Result<()> { + no_network_refusal(no_network, jailed, NSJAIL_AVAILABLE.is_some()) +} + +fn no_network_refusal( + no_network: bool, + jailed: bool, + nsjail_available: bool, +) -> windmill_common::error::Result<()> { + if !no_network || (jailed && nsjail_available) { + return Ok(()); + } + let reason = if jailed { + "nsjail is not available on this worker" + } else { + "this job would not run in an nsjail sandbox: add the `sandbox` annotation, or enable \ + nsjail sandboxing for the instance" + }; + Err(windmill_common::error::Error::ExecutionErr(format!( + "Script has the `no_network` annotation, which removes the network from its nsjail \ + sandbox, but {reason}. Refusing to run it with network." + ))) +} + +/// Whether a script asks for a run step without network, which only nsjail provides. A +/// dedicated worker runs its script outside nsjail, so it leaves such a script to the +/// regular executor. +pub fn runs_no_network_in_jail(language: Option, content: &str) -> bool { + match language { + Some(ScriptLang::Python3) => { + windmill_common::worker::PythonAnnotations::parse(content).no_network + } + Some(ScriptLang::Bun) | Some(ScriptLang::Bunnative) => { + let annotation = windmill_common::worker::TypeScriptAnnotations::parse(content); + annotation.no_network && !annotation.native + } + _ => false, + } +} + +/// Refuses a `no_network` job that imports another workspace script its run step would fetch +/// from the API, which it cannot reach. Without this the fetch fails inside the script, where +/// Python's loader falls back to an empty namespace module and the error names something else. +/// `imports` are workspace paths; the job's own module files (keyed relative to +/// `script_path`'s folder) are on disk and need no fetch. +pub fn check_no_network_imports( + imports: impl IntoIterator, + script_path: &str, + modules: Option<&HashMap>, + fetched_by: &str, +) -> windmill_common::error::Result<()> { + let dir = script_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + let local: Vec = modules + .into_iter() + .flat_map(|m| m.keys()) + .map(|key| { + let stem = key.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(key); + if dir.is_empty() { + stem.to_string() + } else { + format!("{dir}/{stem}") + } + }) + .collect(); + let fetched = imports.into_iter().find(|import| { + !local + .iter() + .any(|l| l == import || l.starts_with(&format!("{import}/"))) + }); + match fetched { + None => Ok(()), + Some(import) => Err(windmill_common::error::Error::ExecutionErr(format!( + "Script has the `no_network` annotation but imports the workspace script \ + `{import}`, which {fetched_by} fetches from the Windmill API while the script runs, \ + and the script has no network. Remove `no_network`, or copy that code into the \ + script." + ))), + } +} + +/// Drops `WM_TOKEN` from a `no_network` run step's env: it is useless without network, and +/// the script could still hand it out through its result. +pub fn strip_no_network_token( + reserved_variables: &mut std::collections::HashMap, + no_network: bool, +) { + if no_network { + reserved_variables.remove("WM_TOKEN"); + } +} + +/// `clone_newnet` for a run step's nsjail profile. +pub fn nsjail_clone_newnet(no_network: bool) -> &'static str { + if no_network { + "true" + } else { + "false" + } +} + +/// [`get_proxy_envs_for_lang`] for a run step. A `no_network` one reaches no proxy, the +/// OTel tracing proxy included, so it gets only the trace context. +pub async fn get_run_proxy_envs( + lang: &ScriptLang, + job_kind: JobKind, + job_id: &uuid::Uuid, + w_id: &str, + conn: &Connection, + no_network: bool, +) -> anyhow::Result> { + if no_network { + return Ok(get_otel_context_envs(job_id)); + } + get_proxy_envs_for_lang(lang, job_kind, job_id, w_id, conn).await +} + +#[cfg(test)] +mod no_network_tests { + use super::{check_no_network_imports, no_network_refusal, runs_no_network_in_jail}; + use windmill_common::scripts::ScriptLang; + + #[test] + fn no_network_runs_only_in_an_available_jail() { + assert!(no_network_refusal(false, false, false).is_ok()); + assert!(no_network_refusal(true, true, true).is_ok()); + assert!(no_network_refusal(true, false, true).is_err()); + assert!(no_network_refusal(true, true, false).is_err()); + assert!(no_network_refusal(true, false, false).is_err()); + } + + #[test] + fn no_network_refuses_only_imports_it_would_fetch() { + let modules = std::collections::HashMap::from([( + "lib/util.py".to_string(), + windmill_common::scripts::ScriptModule { + content: String::new(), + lock: None, + language: ScriptLang::Python3, + }, + )]); + let check = |imports: &[&str]| { + check_no_network_imports( + imports.iter().map(|s| s.to_string()), + "u/admin/main", + Some(&modules), + "Python", + ) + }; + assert!(check(&["u/admin/lib/util", "u/admin/lib"]).is_ok()); + let err = check(&["u/admin/lib/util", "u/admin/helper"]).unwrap_err(); + assert!(err.to_string().contains("`u/admin/helper`"), "{err}"); + } + + #[test] + fn dedicated_workers_leave_jailed_no_network_scripts() { + let py = Some(ScriptLang::Python3); + let bun = Some(ScriptLang::Bun); + assert!(runs_no_network_in_jail( + py, + "# no_network\ndef main(): pass" + )); + assert!(!runs_no_network_in_jail(py, "def main(): pass")); + assert!(runs_no_network_in_jail( + bun, + "// no_network\nexport function main() {}" + )); + // Native scripts take their network away inside the isolate. + assert!(!runs_no_network_in_jail( + bun, + "//native\n//no_network\nexport function main() {}" + )); + } +} + /// Returns true if unshare PID isolation should be used (when not using nsjail). /// ENABLE_UNSHARE_PID forces unshare regardless of the global setting. /// FAVOR_UNSHARE_PID uses unshare only when the global setting is not set. diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 5cf07a243b..7d0cf5c1cc 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -1419,7 +1419,7 @@ export async function main(url: string) { ## Pure computation: \`//no_network\` -A native script that only transforms its inputs can declare \`//no_network\` in its leading comment block (right after \`//native\`). The runtime then refuses every connection the script attempts: \`fetch\` to any host, the Windmill API (so \`windmill-client\` calls fail too), raw sockets and unix sockets, and \`WM_TOKEN\` is not set. It only takes effect on native scripts: without \`//native\` it is ignored. Use it only when the script needs no external data beyond its arguments: +A native script that only transforms its inputs can declare \`//no_network\` in its leading comment block (right after \`//native\`). The runtime then refuses every connection the script attempts: \`fetch\` to any host, the Windmill API (so \`windmill-client\` calls fail too), raw sockets and unix sockets, and \`WM_TOKEN\` is not set. Without \`//native\` it means something else: the script must run in an nsjail sandbox (\`//sandbox\`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments: \`\`\`typescript //native diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 5363053bd2..5b8c3cbe8c 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -4963,7 +4963,7 @@ export async function main(url: string) { ## Pure computation: \`//no_network\` -A native script that only transforms its inputs can declare \`//no_network\` in its leading comment block (right after \`//native\`). The runtime then refuses every connection the script attempts: \`fetch\` to any host, the Windmill API (so \`windmill-client\` calls fail too), raw sockets and unix sockets, and \`WM_TOKEN\` is not set. It only takes effect on native scripts: without \`//native\` it is ignored. Use it only when the script needs no external data beyond its arguments: +A native script that only transforms its inputs can declare \`//no_network\` in its leading comment block (right after \`//native\`). The runtime then refuses every connection the script attempts: \`fetch\` to any host, the Windmill API (so \`windmill-client\` calls fail too), raw sockets and unix sockets, and \`WM_TOKEN\` is not set. Without \`//native\` it means something else: the script must run in an nsjail sandbox (\`//sandbox\`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments: \`\`\`typescript //native diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 096cabab93..509e34d502 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -379,7 +379,7 @@ export async function main(url: string) { ## Pure computation: `//no_network` -A native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. It only takes effect on native scripts: without `//native` it is ignored. Use it only when the script needs no external data beyond its arguments: +A native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. Without `//native` it means something else: the script must run in an nsjail sandbox (`//sandbox`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments: ```typescript //native diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index cc93c352c4..1c7c1294a6 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -124,7 +124,7 @@ export async function main(url: string) { ## Pure computation: `//no_network` -A native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. It only takes effect on native scripts: without `//native` it is ignored. Use it only when the script needs no external data beyond its arguments: +A native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. Without `//native` it means something else: the script must run in an nsjail sandbox (`//sandbox`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments: ```typescript //native diff --git a/system_prompts/languages/bunnative.md b/system_prompts/languages/bunnative.md index af9055f01d..3d5e7bccbc 100644 --- a/system_prompts/languages/bunnative.md +++ b/system_prompts/languages/bunnative.md @@ -49,7 +49,7 @@ export async function main(url: string) { ## Pure computation: `//no_network` -A native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. It only takes effect on native scripts: without `//native` it is ignored. Use it only when the script needs no external data beyond its arguments: +A native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. Without `//native` it means something else: the script must run in an nsjail sandbox (`//sandbox`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments: ```typescript //native diff --git a/typescript-client/client.ts b/typescript-client/client.ts index da957239cf..dbb667a3aa 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -137,7 +137,9 @@ export async function getResource( return undefined; } else { throw Error( - `Resource not found at ${path} or not visible to you: ${e.body}` + e.status === undefined + ? `Could not reach the Windmill API to get resource ${path}: ${e.message ?? e}` + : `Resource not found at ${path} or not visible to you: ${e.body}` ); } } @@ -831,7 +833,9 @@ export async function getVariable(path: string): Promise { return await VariableService.getVariableValue({ workspace, path }); } catch (e: any) { throw Error( - `Variable not found at ${path} or not visible to you: ${e.body}` + e.status === undefined + ? `Could not reach the Windmill API to get variable ${path}: ${e.message ?? e}` + : `Variable not found at ${path} or not visible to you: ${e.body}` ); } }