diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index cf86c778..cae6c29b 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -631,6 +631,10 @@ impl BindPorts { } } +fn default_registration() -> bool { + true +} + /// Sandbox configuration. #[derive(Serialize, Deserialize)] pub struct Sandbox { @@ -816,6 +820,11 @@ pub struct Sandbox { #[serde(skip)] pub name: Option, + /// Register this instance for discovery and control by name (default: true). + /// Disabled instances reserve no name. Applies at creation, not to live instances. + #[serde(skip, default = "default_registration")] + pub registration: bool, + /// Operating-mode marker (e.g. "learn") served by the control socket /// and shown as STATUS by `sandlock ps`, so an operator sees /// why a sandbox exists (learn's read-everything observation run would @@ -925,6 +934,7 @@ impl Clone for Sandbox { user: self.user, policy_fn: self.policy_fn.clone(), name: self.name.clone(), + registration: self.registration, mode: self.mode.clone(), // init_fn (FnOnce) cannot be cloned — the clone gets None. // If the clone also needs an init function, set it explicitly. @@ -2473,16 +2483,22 @@ impl Sandbox { // Claim before fork so a name collision leaves no child to reap. let sandbox_name = self.rt().name.clone(); - let mut control_socket = match crate::control::bind_control_socket(&sandbox_name) { - Ok(s) => Some(s), - Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { + let bound = if self.registration { + Some(crate::control::bind_control_socket(&sandbox_name)) + } else { + None + }; + let mut control_socket = match bound { + None => None, + Some(Ok(s)) => Some(s), + Some(Err(e)) if e.kind() == std::io::ErrorKind::AddrInUse => { return Err(SandboxRuntimeError::Child(format!( "sandbox '{}' is already running", sandbox_name )) .into()); } - Err(e) + Some(Err(e)) if matches!( e.raw_os_error(), Some(libc::EPERM | libc::EACCES | libc::ENOSYS) @@ -2498,7 +2514,7 @@ impl Sandbox { ); None } - Err(e) => return Err(SandboxRuntimeError::Io(e).into()), + Some(Err(e)) => return Err(SandboxRuntimeError::Io(e).into()), }; let child_publisher = control_socket .as_ref() diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 27904423..a1abec60 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -235,6 +235,9 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", clap(skip))] pub name: Option, + #[cfg_attr(feature = "cli", clap(skip = true))] + pub registration: bool, + // Operating-mode marker (e.g. "learn") shown as STATUS by `sandlock ps`: // gives operators context for otherwise alarming sandboxes like learn's // read-everything observation run. @@ -320,6 +323,7 @@ impl Default for SandboxBuilder { protection_policy: ProtectionPolicy::default(), policy_fn: None, name: None, + registration: true, mode: None, init_fn: None, work_fn: None, @@ -385,6 +389,7 @@ impl Clone for SandboxBuilder { protection_policy: self.protection_policy.clone(), policy_fn: self.policy_fn.clone(), name: self.name.clone(), + registration: self.registration, mode: self.mode.clone(), // init_fn (FnOnce) cannot be cloned; drop to None. init_fn: None, @@ -869,6 +874,13 @@ impl SandboxBuilder { self } + /// Enable registration for discovery and control by name (default: true). + /// Disabled instances do not reserve their names. Not serialized as policy. + pub fn registration(mut self, enabled: bool) -> Self { + self.registration = enabled; + self + } + /// Set the operating-mode marker shown as STATUS in `sandlock ps` /// output (e.g. `"learn"`). pub fn mode(mut self, mode: impl Into) -> Self { @@ -1272,6 +1284,7 @@ impl SandboxBuilder { user: self.user, policy_fn, name: self.name, + registration: self.registration, mode: self.mode, cow_upper: None, init_fn: self.init_fn, diff --git a/crates/sandlock-core/tests/registration_disabled.rs b/crates/sandlock-core/tests/registration_disabled.rs new file mode 100644 index 00000000..1d1293f5 --- /dev/null +++ b/crates/sandlock-core/tests/registration_disabled.rs @@ -0,0 +1,75 @@ +use sandlock_core::Sandbox; + +fn sandbox(name: &str) -> Sandbox { + Sandbox::builder() + .fs_read("/usr") + .fs_read("/bin") + .fs_read("/lib") + .fs_read_if_exists("/lib64") + .build() + .unwrap() + .with_name(name) +} + +async fn check_registration(no_supervisor: bool) { + let name = format!("test-registration-{}-{no_supervisor}", std::process::id()); + let mut private = sandbox(&name); + private.registration = false; + private.no_supervisor = no_supervisor; + private.create(&["sleep", "30"]).await.unwrap(); + assert!(!sandlock_core::control::list_sandboxes() + .unwrap() + .contains(&name)); + + let mut registered = sandbox(&name); + registered.no_supervisor = no_supervisor; + registered.create(&["sleep", "30"]).await.unwrap(); + assert!(sandlock_core::control::list_sandboxes() + .unwrap() + .contains(&name)); + + let result = private.clone().run(&["true"]).await.unwrap(); + assert!(result.success()); + let error = sandbox(&name).create(&["true"]).await.unwrap_err(); + assert!(error.to_string().contains("already running")); + + private.kill().unwrap(); + registered.kill().unwrap(); +} + +#[tokio::test] +async fn registration_is_per_sandbox() { + check_registration(false).await; +} + +#[tokio::test] +async fn registration_is_per_sandbox_without_supervisor() { + check_registration(true).await; +} + +#[test] +fn registration_is_not_serialized_policy() { + let builder = Sandbox::builder().registration(false); + let sandbox = builder.clone().build().unwrap(); + assert!(!sandbox.registration); + let mut policy = serde_json::to_value(&sandbox).unwrap(); + assert!(policy.get("registration").is_none()); + policy["registration"] = false.into(); + let restored: Sandbox = serde_json::from_value(policy).unwrap(); + assert!(restored.registration); +} + +#[cfg(feature = "cli")] +#[test] +fn cli_builder_registers_by_default() { + use clap::{Args, FromArgMatches}; + use sandlock_core::sandbox::SandboxBuilder; + + let matches = + SandboxBuilder::augment_args(clap::Command::new("test")).get_matches_from(["test"]); + let sandbox = SandboxBuilder::from_arg_matches(&matches) + .unwrap() + .build() + .unwrap(); + assert!(sandbox.registration); +} diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index f4f5b34b..3249c1c1 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -748,6 +748,16 @@ sandlock_builder_t *sandlock_sandbox_builder_extra_allow_syscalls(sandlock_build */ int64_t sandlock_syscall_nr(const char *name); +/** + * Set this instance's registration for discovery and control by name. + * Defaults to true; false skips the name claim and control socket. + * This setting is not serialized as policy. + * + * # Safety + * `b` must be a valid builder pointer or NULL. + */ +sandlock_builder_t *sandlock_sandbox_builder_registration(sandlock_builder_t *b, bool enabled); + /** * # Safety * `b` must be a valid builder pointer. diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 414c0e75..feacc17b 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -836,6 +836,24 @@ pub unsafe extern "C" fn sandlock_syscall_nr(name: *const c_char) -> i64 { } } +/// Set this instance's registration for discovery and control by name. +/// Defaults to true; false skips the name claim and control socket. +/// This setting is not serialized as policy. +/// +/// # Safety +/// `b` must be a valid builder pointer or NULL. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_registration( + b: *mut SandboxBuilder, + enabled: bool, +) -> *mut SandboxBuilder { + if b.is_null() { + return b; + } + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.registration(enabled))) +} + /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] diff --git a/docs/architecture.md b/docs/architecture.md index 16ed854b..bd9604b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,6 +112,19 @@ clients and supervisors must share the IPC namespace used for discovery and the network namespace containing their abstract sockets. This protocol does not discover sandboxes started by older versions using name-based socket addresses. +A host program can disable registration for an individual sandbox with +`SandboxBuilder::registration(false)` +(FFI `sandlock_sandbox_builder_registration`, Python +`Sandbox(registration=False)`, Go `Sandbox{DisableRegistration: true}`). +Registration defaults to enabled and is an instance setting, excluded from +serialized policy. An unregistered sandbox claims no name, binds no socket, +and runs no control loop: it is invisible to `sandlock ps`, unreachable by +`sandlock kill`, and does not reserve a UID-wide unique name. Its name still +sets the virtual hostname, and direct `Controller` and SDK lifecycle methods +continue to work. Registered and unregistered sandboxes can coexist in one +process. Set the option before creating the sandbox; changing it does not +alter an already running instance. + Sandlock does not create an IPC namespace. The default seccomp policy denies semaphore and shared-memory syscalls, including in unsupervised mode. Knowing the registry ID does not let a confined workload read, modify, or remove diff --git a/docs/cli.md b/docs/cli.md index 2fe5e65a..4840d0aa 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -254,6 +254,9 @@ Killed sandbox 'web.local' Ports show as `virtual→real` when `--port-remap` is active; see [`network.md`](network.md#port-virtualization). +Sandboxes created with SDK registration disabled do not appear here and +cannot be killed by name. Registration is enabled by default. + ## Generating a profile `sandlock learn` runs a workload under observation and emits a profile diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 6828fe43..e91e47db 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -414,6 +414,7 @@ and have no TOML counterpart. | ----------- | ----------------- | ------- | ---------------------------------------------------------------------------------------------------------- | | `name` | `str \| None` | `None` | Sandbox name and virtual hostname inside the sandbox. Auto-generated as `sandbox-{pid}` when omitted. Maximum 64 bytes; must not contain NUL. | | `image` | `Image \| None` | `None` | Container image from `sandlock.pull_image()`. Its rootfs becomes the chroot; its env and working directory fill only what `env` and `cwd` leave unset. Writes land in a copy-on-write branch that is always discarded, so the cached image never changes: `workdir` and a non-abort `on_error` are rejected, and output meant to persist goes through `fs_mount`. Runtime rather than policy because it names a local cache path. | +| `registration` | `bool` | `True` | Register this instance for discovery and control by name. If false, skip the name claim and control socket; names are not reserved. Set before creation; excluded from policy serialization. Go exposes this as `DisableRegistration` (default `false`). | | `policy_fn` | `PolicyFn \| None`| `None` | Callback configuration, including its verdict timeout. See [`policy-fn.md`](policy-fn.md). | | `init_fn` | `Callable \| None`| `None` | Callback invoked once in the template process prior to COW fork. | | `work_fn` | `Callable \| None`| `None` | Callback invoked in each COW clone; receives `clone_id` as its argument. | diff --git a/go/README.md b/go/README.md index 6d69726d..096eed1f 100644 --- a/go/README.md +++ b/go/README.md @@ -307,6 +307,16 @@ configuration that needs a supervisor or a fresh child (seccomp, network, resource limits, environment, ...) is rejected rather than silently ignored. This is something the `sandlock` CLI cannot do. +### Sandbox registration + +Registration is enabled by default. Set `DisableRegistration: true` on a +`Sandbox` to skip its name claim and control socket. That sandbox will not +appear in `sandlock ps` or accept `sandlock kill` by name, and its name will +not be reserved. Its virtual hostname and direct process lifecycle methods +still work. Other sandboxes in the same process are unaffected. Set the +option before starting a new process; it does not change a running process +and is not part of serialized policy. + ### Platform ```go diff --git a/go/registration_linux_test.go b/go/registration_linux_test.go new file mode 100644 index 00000000..0cec2973 --- /dev/null +++ b/go/registration_linux_test.go @@ -0,0 +1,47 @@ +//go:build linux + +package sandlock_test + +import ( + "context" + "fmt" + "os" + "testing" + + sandlock "github.com/multikernel/sandlock/go" +) + +func TestRegistrationIsPerSandbox(t *testing.T) { + requireLandlock(t) + name := fmt.Sprintf("go-unregistered-%d", os.Getpid()) + holder := &sandlock.Sandbox{FSReadable: rootfs, Name: name, DisableRegistration: true} + p, err := holder.Spawn("sleep", "30") + if err != nil { + t.Fatalf("Spawn: %v", err) + } + defer p.Close() + defer p.Kill() + + registered := &sandlock.Sandbox{FSReadable: rootfs, Name: name} + r, err := registered.Spawn("sleep", "30") + if err != nil { + t.Fatalf("registered Spawn: %v", err) + } + defer r.Close() + defer r.Kill() + + sb := &sandlock.Sandbox{FSReadable: rootfs, Name: name, DisableRegistration: true} + res, err := sb.Run(context.Background(), "true") + if err != nil { + t.Fatalf("Run with a reused name: %v", err) + } + if !res.Success { + t.Fatalf("Run failed: exit=%d stderr=%q", res.ExitCode, res.Stderr) + } + duplicate, err := registered.Spawn("true") + if err == nil { + defer duplicate.Close() + defer duplicate.Kill() + t.Fatal("registered Spawn accepted a duplicate name") + } +} diff --git a/go/sandbox.go b/go/sandbox.go index 867cf78c..fa53fc95 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -321,6 +321,10 @@ type Sandbox struct { // Empty auto-generates "sandbox-{pid}". Name string + // DisableRegistration skips this instance's name claim and control socket. + // Set before spawning; existing processes and other sandboxes are unaffected. + DisableRegistration bool `json:"-"` + // PolicyFn receives dynamic syscall events and may return an allow/deny // decision or modify live policy through the supplied context. PolicyFn *PolicyFn diff --git a/go/sandlock_linux.go b/go/sandlock_linux.go index ed8ed019..e07fa2df 100644 --- a/go/sandlock_linux.go +++ b/go/sandlock_linux.go @@ -239,6 +239,7 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { } b := C.sandlock_sandbox_builder_new() + b = C.sandlock_sandbox_builder_registration(b, cbool(!s.DisableRegistration)) // str calls a one-string builder setter, freeing the C string afterward. str := func(fn func(*C.sandlock_builder_t, *C.char) *C.sandlock_builder_t, val string) { diff --git a/python/README.md b/python/README.md index d2d5c5e2..df059aa6 100644 --- a/python/README.md +++ b/python/README.md @@ -73,7 +73,7 @@ network, resource limits, etc.) and runtime state. Construct once, then call `run()` (blocking) or `spawn()` + lifecycle methods, or use as a context manager. All config fields are optional. Unset fields mean "no restriction" unless -noted otherwise. Runtime kwargs (`name`, `policy_fn`, `init_fn`, `work_fn`) +noted otherwise. Runtime kwargs (`name`, `registration`, `policy_fn`, `init_fn`, `work_fn`) are set at construction time alongside config fields. A single `Sandbox` instance holds at most one running process at a time. @@ -81,6 +81,13 @@ For concurrent execution, create multiple instances. **Runtime kwargs:** +- `registration`: defaults to `True`. Set `Sandbox(registration=False)` to + skip this instance's name claim and control socket. It will not appear in + `sandlock ps` or accept `sandlock kill` by name, and its name will not be + reserved. The virtual hostname and direct lifecycle methods still work. + Set before creation; other sandboxes and existing running instances are + unaffected. This setting is excluded from serialized policy. + - `name` -- sandbox name (also its virtual hostname inside the sandbox). Auto-generated as `sandbox-{pid}` when omitted. - `policy_fn` -- optional callback for dynamic per-event policy decisions diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 611195bc..25ce9ab9 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -141,6 +141,7 @@ def _builder_fn(name, *extra_args): _b_no_randomize_memory = _builder_fn("sandlock_sandbox_builder_no_randomize_memory", ctypes.c_bool) _b_no_huge_pages = _builder_fn("sandlock_sandbox_builder_no_huge_pages", ctypes.c_bool) _b_no_coredump = _builder_fn("sandlock_sandbox_builder_no_coredump", ctypes.c_bool) +_b_registration = _builder_fn("sandlock_sandbox_builder_registration", ctypes.c_bool) _b_deterministic_dirs = _builder_fn("sandlock_sandbox_builder_deterministic_dirs", ctypes.c_bool) _b_cpu_cores = _builder_fn("sandlock_sandbox_builder_cpu_cores", ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32) @@ -1310,7 +1311,7 @@ def __del__(self): "max_memory", "max_disk", "max_processes", "max_cpu", "num_cpus", "cpu_cores", "gpu_devices", "net_allow", "net_deny", "net_allow_bind", "net_deny_bind", - "port_remap", + "port_remap", "registration", "http_allow", "http_deny", "http_ports", "http_ca", "http_key", "uid", "random_seed", "time_start", "clean_env", "env", @@ -1328,6 +1329,7 @@ def __del__(self): def _build_from_policy(policy: PolicyDataclass): """Build a native builder from a Python Sandbox dataclass. Returns builder pointer.""" b = _lib.sandlock_sandbox_builder_new() + b = _b_registration(b, policy.registration) for p in (policy.fs_readable or []): if str(p) == "/lib64" and not os.path.exists("/lib64"): diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 91242b89..481af63f 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -294,7 +294,7 @@ class Sandbox: Most config fields are optional — unset fields mean "no restriction". Sandlock's default syscall blocklist is always applied. - Runtime kwargs (``name``, ``image``, ``policy_fn``, ``init_fn``, + Runtime kwargs (``name``, ``registration``, ``image``, ``policy_fn``, ``init_fn``, ``work_fn``) have ``metadata={"runtime": True}`` so serializers can skip them. """ @@ -570,6 +570,11 @@ class Sandbox: """Sandbox name (also exposed as the virtual hostname inside the sandbox). Auto-generated as ``sandbox-{pid}-{counter}`` when omitted.""" + registration: bool = field(default=True, metadata={"runtime": True}) + """Register this instance for discovery and control by name. False skips + its name claim and control socket. Set before creation; running instances + and other sandboxes are unaffected.""" + policy_fn: PolicyFn | None = field(default=None, repr=False, metadata={"runtime": True}) """Optional callback for dynamic per-event policy decisions.""" diff --git a/python/tests/test_registration.py b/python/tests/test_registration.py new file mode 100644 index 00000000..d8700778 --- /dev/null +++ b/python/tests/test_registration.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +import pytest + +from sandlock import Sandbox + + +def test_registration_is_per_sandbox(): + readable = ["/usr", "/lib", "/lib64", "/bin", "/etc", sys.prefix] + name = f"py-registration-{os.getpid()}" + private = Sandbox(name=name, fs_readable=readable, registration=False) + registered = Sandbox(name=name, fs_readable=readable) + private.spawn(["sleep", "30"]) + try: + registered.spawn(["sleep", "30"]) + try: + result = Sandbox( + name=name, fs_readable=readable, registration=False, + ).run(["true"]) + assert result.success, result.error + with pytest.raises(RuntimeError, match="sandlock_create failed"): + Sandbox(name=name, fs_readable=readable).spawn(["true"]) + finally: + registered.kill() + finally: + private.kill()