Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,10 @@ impl BindPorts {
}
}

fn default_registration() -> bool {
true
}

/// Sandbox configuration.
#[derive(Serialize, Deserialize)]
pub struct Sandbox {
Expand Down Expand Up @@ -816,6 +820,11 @@ pub struct Sandbox {
#[serde(skip)]
pub name: Option<String>,

/// 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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions crates/sandlock-core/src/sandbox/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ pub struct SandboxBuilder {
#[cfg_attr(feature = "cli", clap(skip))]
pub name: Option<String>,

#[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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>) -> Self {
Expand Down Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions crates/sandlock-core/tests/registration_disabled.rs
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 10 additions & 0 deletions crates/sandlock-ffi/include/sandlock.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions crates/sandlock-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/sandbox-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
10 changes: 10 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions go/registration_linux_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
4 changes: 4 additions & 0 deletions go/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions go/sandlock_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,21 @@ 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.
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
Expand Down
Loading
Loading