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 <noreply@anthropic.com>

* fix: keep nodejs relative imports and dedicated workers working under no_network

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: bundle bun no_network jobs so relative imports resolve before the run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: point ee-repo-ref at the runner group no_network fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: state the no_network token rule once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: refuse no_network jobs whose workspace imports would be fetched at run time

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let type-only workspace imports through the no_network import check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-17 09:25:55 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent b0c97b34ca
commit feb7b1ad90
21 changed files with 407 additions and 62 deletions
+1 -1
View File
@@ -1 +1 @@
455681e55eb7cdfcfdb7cd79cbae75b333e07943
ca4a1959a0d2257d413a57349939053ac3473c6b
+14 -1
View File
@@ -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<Vec<String>> {
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<Vec<String>> {
resolve_relative_imports(code, path, true)
}
fn resolve_relative_imports(
code: &str,
path: &str,
skip_type_only: bool,
) -> anyhow::Result<Vec<String>> {
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<String> = imports
@@ -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#"
+19
View File
@@ -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";
@@ -9,7 +9,7 @@ disable_rl: true
cwd: "/tmp"
clone_newnet: false
clone_newnet: {CLONE_NEWNET}
clone_newuser: {CLONE_NEWUSER}
skip_setsid: true
@@ -11,7 +11,7 @@ mount_proc: true
cwd: "/tmp/{LANG}"
clone_newnet: false
clone_newnet: {CLONE_NEWNET}
clone_newuser: {CLONE_NEWUSER}
clone_newcgroup: false
@@ -9,7 +9,7 @@ disable_rl: true
cwd: {WORKDIR}
clone_newnet: false
clone_newnet: {CLONE_NEWNET}
clone_newuser: {CLONE_NEWUSER}
skip_setsid: true
@@ -12,7 +12,7 @@ rlimit_nofile: 10000
cwd: "/tmp"
clone_newnet: false
clone_newnet: {CLONE_NEWNET}
clone_newuser: {CLONE_NEWUSER}
skip_setsid: true
@@ -8,7 +8,7 @@ disable_rl: true
cwd: "/tmp"
clone_newnet: false
clone_newnet: {CLONE_NEWNET}
clone_newuser: {CLONE_NEWUSER}
skip_setsid: true
+32 -16
View File
@@ -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?,
)
+52 -10
View File
@@ -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}"
})
+11 -4
View File
@@ -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<String, Error> {
#[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<CanceledBy>,
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?;
+30 -5
View File
@@ -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?,
)
+35 -12
View File
@@ -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?
+183
View File
@@ -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<ScriptLang>, 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<Item = String>,
script_path: &str,
modules: Option<&HashMap<String, ScriptModule>>,
fetched_by: &str,
) -> windmill_common::error::Result<()> {
let dir = script_path
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("");
let local: Vec<String> = 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<String, String>,
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<Vec<(&'static str, String)>> {
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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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
+6 -2
View File
@@ -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<string> {
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}`
);
}
}