Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bb1fbbc
profile: derive Serialize on all profile section types
ghazariann Jun 23, 2026
7248424
seccomp: add audit hooks for file access and network connect
ghazariann Jun 23, 2026
4cfc460
cli: add sandlock learn subcommand
ghazariann Jun 23, 2026
62514ce
profile: add to_toml() method, remove toml dep from sandlock-cli
ghazariann Jun 23, 2026
eab8c1b
core: extend file hook to execve, fix sendto args, add sendmsg
ghazariann Jun 23, 2026
1ee9176
learn: COW observation, parent-dir write collapsing, execve binary ca…
ghazariann Jun 23, 2026
8564a37
tests: add learn round-trip tests for fs read, write, and network
ghazariann Jun 23, 2026
cb3aebc
core: fix on_net_connect doc, shorten comment
ghazariann Jun 23, 2026
7321099
cli: fix learn header, remove minimal from subcommand description
ghazariann Jun 23, 2026
16db5d5
core: fix missing audit fields in NotifPolicy test fixtures
ghazariann Jun 23, 2026
e6f6268
core: add on_execve audit hook for execve/execveat
ghazariann Jun 26, 2026
a342bbf
learn: use on_execve hook for binary capture
ghazariann Jun 26, 2026
319deb1
core: read dynamic linker from /proc/pid/maps after execve
ghazariann Jun 26, 2026
e14ce29
learn: remove ELF PT_INTERP parser, linker now captured via maps
ghazariann Jun 26, 2026
9983f75
profile: skip default fields/sections in TOML serialization
ghazariann Jul 3, 2026
050de2d
learn: resource peaks via create/start/wait, populate [program].exec
ghazariann Jul 3, 2026
7ded9fa
tests: assert resource limits populated by sandlock learn
ghazariann Jul 3, 2026
3456857
learn: replace on_file_access/on_execve/on_net_connect hooks with pol…
ghazariann Jul 5, 2026
7c0f372
core: remove stale maps-reading section comment from notif.rs
ghazariann Jul 5, 2026
e40fe62
learn: add kernel version and timestamp to profile header
ghazariann Jul 5, 2026
cb35f51
fix sendto sockaddr args, add sendmsg extraction, distinguish UDP/TCP…
ghazariann Jul 11, 2026
b648c2f
correct SyscallEvent.path doc: path is read from child memory, not /p…
ghazariann Jul 11, 2026
5e8eb1f
tests: add openat2 test, fix read/write assertions to check profile s…
ghazariann Jul 13, 2026
ac06a92
fix openat2 path/flags in policy events, guard is_write_open against …
ghazariann Jul 13, 2026
78319c0
learn: fix COW isolation -- workdir=/ intercepts all writes, abort on…
ghazariann Jul 13, 2026
4ef1260
learn: refuse to write profile if workload exits abnormally
ghazariann Jul 13, 2026
354e53e
learn: add --timeout flag; kill and write partial profile on expiry
ghazariann Jul 13, 2026
5b0640b
learn: capture mkdir/unlink/rename/symlink/link/truncate syscalls
ghazariann Jul 14, 2026
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
2 changes: 1 addition & 1 deletion crates/sandlock-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
jiff = "0.2"
libc = "0.2"
tempfile = "3"

[dev-dependencies]
tempfile = "3"
375 changes: 375 additions & 0 deletions crates/sandlock-cli/src/learn.rs

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::path::PathBuf;
use std::time::SystemTime;

mod network_registry;
mod learn;

#[derive(Parser)]
#[command(name = "sandlock", about = "Lightweight process sandbox", version)]
Expand All @@ -33,6 +34,8 @@ enum Command {
#[command(subcommand)]
action: ProfileAction,
},
/// Observe a workload and emit a sandlock profile
Learn(LearnArgs),
}

/// Arguments for the `run` subcommand.
Expand Down Expand Up @@ -200,6 +203,22 @@ enum ProfileAction {
Delete { name: String },
}

/// Arguments for the `learn` subcommand.
#[derive(clap::Args)]
struct LearnArgs {
/// Write observed profile to this file (default: print to stdout)
#[arg(short = 'o', long, value_name = "PATH")]
output: Option<PathBuf>,

/// Kill the observed process after this many seconds and write a partial profile
#[arg(long, value_name = "SECS")]
timeout: Option<u64>,

/// Command to observe (everything after --)
#[arg(last = true, required = true)]
cmd: Vec<String>,
}

#[derive(serde::Serialize)]
struct SandboxStatus {
exit_code: i32,
Expand Down Expand Up @@ -303,6 +322,10 @@ async fn main() -> Result<()> {
println!(" Platform: {}", std::env::consts::ARCH);
}

Command::Learn(args) => {
learn::run(args).await?;
}

Command::Profile { action } => {
match action {
ProfileAction::List => {
Expand Down
473 changes: 472 additions & 1 deletion crates/sandlock-cli/tests/cli_test.rs

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions crates/sandlock-core/src/policy_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ pub struct SyscallEvent {
pub argv: Option<Vec<String>>,
/// Whether the supervisor denied this syscall.
pub denied: bool,
/// Resolved absolute path for file syscalls (openat, execve/execveat,
/// mkdirat, unlinkat, symlinkat, truncate, renameat2 src, linkat src).
/// Read from child user memory, not TOCTOU-safe for enforcement but
/// sufficient for learn-mode observation.
/// `None` for non-file syscalls or when resolution fails.
pub path: Option<std::path::PathBuf>,
/// Second resolved path for two-path syscalls (renameat2 dst, linkat dst).
/// `None` for single-path syscalls.
pub path2: Option<std::path::PathBuf>,
/// Open flags for openat (the `flags` argument, e.g. `O_RDONLY`,
/// `O_WRONLY`, `O_CREAT`). `None` for non-openat syscalls.
pub flags: Option<u64>,
}

impl SyscallEvent {
Expand Down Expand Up @@ -483,6 +495,9 @@ mod tests {
size: None,
argv: Some(vec!["python3".into(), "-c".into(), "print(1)".into()]),
denied: false,
path: None,
path2: None,
flags: None,
};
assert!(event.argv_contains("python3"));
assert!(event.argv_contains("-c"));
Expand All @@ -502,6 +517,9 @@ mod tests {
size: None,
argv: None,
denied: false,
path: None,
path2: None,
flags: None,
};
assert!(!event.argv_contains("anything"));
}
Expand Down
84 changes: 73 additions & 11 deletions crates/sandlock-core/src/profile.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::sandbox::{ByteSize, Sandbox};
use crate::error::SandlockError;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::collections::HashMap;
use std::time::SystemTime;
Expand All @@ -14,129 +14,191 @@ pub struct ProgramSpec {
}

/// Top-level profile input. Each section maps to one schema section.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct ProfileInput {
#[serde(skip_serializing_if = "is_default")]
pub config: ConfigSection,
#[serde(skip_serializing_if = "is_default")]
pub determinism: DeterminismSection,
#[serde(skip_serializing_if = "is_default")]
pub program: ProgramSection,
#[serde(skip_serializing_if = "is_default")]
pub filesystem: FilesystemSection,
#[serde(skip_serializing_if = "is_default")]
pub network: NetworkSection,
#[serde(skip_serializing_if = "is_default")]
pub http: HttpSection,
#[serde(skip_serializing_if = "is_default")]
pub syscalls: SyscallsSection,
#[serde(skip_serializing_if = "is_default")]
pub limits: LimitsSection,
}

fn is_false(b: &bool) -> bool { !b }
fn is_default<T: Default + PartialEq>(v: &T) -> bool { *v == T::default() }

// Field names follow the schema vocabulary and match `Sandbox`'s field names 1:1.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct ConfigSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub http_ca: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_key: Option<PathBuf>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub http_inject_ca: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_ca_out: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fs_storage: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workdir: Option<PathBuf>,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct DeterminismSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub random_seed: Option<u64>,
/// RFC3339 timestamp string. Maps to `Sandbox::time_start`.
#[serde(skip_serializing_if = "Option::is_none")]
pub time_start: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub deterministic_dirs: bool,
#[serde(skip_serializing_if = "is_false")]
pub no_randomize_memory: bool,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct ProgramSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub exec: Option<PathBuf>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gid: Option<u32>,
#[serde(skip_serializing_if = "is_false")]
pub clean_env: bool,
#[serde(skip_serializing_if = "is_false")]
pub no_coredump: bool,
#[serde(skip_serializing_if = "is_false")]
pub no_huge_pages: bool,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct FilesystemSection {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub read: Vec<PathBuf>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub write: Vec<PathBuf>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chroot: Option<PathBuf>,
/// Each entry has the form `"VIRTUAL:HOST"`, matching `--fs-mount` syntax.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub mount: Vec<String>,
/// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_exit`.
#[serde(skip_serializing_if = "Option::is_none")]
pub on_exit: Option<String>,
/// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_error`.
#[serde(skip_serializing_if = "Option::is_none")]
pub on_error: Option<String>,
}

/// One `[network].allow_bind` entry: a bare integer port (`8080`) or a
/// quoted string holding a comma list and/or `lo-hi` range (`"9000-9005"`).
/// The untagged form lets a TOML array mix the two, e.g.
/// `allow_bind = [8080, "9000-9005"]`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(untagged)]
pub enum PortSpec {
Port(u16),
Spec(String),
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct NetworkSection {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub allow_bind: Vec<PortSpec>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub deny_bind: Vec<PortSpec>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
#[serde(skip_serializing_if = "is_false")]
pub port_remap: bool,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct HttpSection {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ports: Vec<u16>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct SyscallsSection {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub extra_allow: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub extra_deny: Vec<String>,
}

// Field names drop the `max_` prefix that `Sandbox` uses (`memory`, not
// `max_memory`) — the section name `[limits]` makes the prefix redundant.
// `parse_input` maps each of these to the corresponding `Sandbox::max_*` field.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct LimitsSection {
/// `ByteSize` string, e.g. `"512M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
/// not yet supported). Maps to `Sandbox::max_memory`.
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub processes: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub open_files: Option<u32>,
/// CPU cap as a percentage (0–100). Maps to `Sandbox::max_cpu`.
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu: Option<u8>,
/// `ByteSize` string, e.g. `"256M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
/// not yet supported). Maps to `Sandbox::max_disk`.
#[serde(skip_serializing_if = "Option::is_none")]
pub disk: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gpu_devices: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu_cores: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_cpus: Option<u32>,
}

/// Convert a parsed `ProfileInput` into a `(Sandbox, ProgramSpec)` pair.
///
impl ProfileInput {
/// Serialize the profile to a TOML string.
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string(self)
}
}

/// Forwards each schema section's fields to the corresponding `SandboxBuilder`
/// method calls. The two private helpers (`parse_branch_action`,
/// `parse_mount_spec`) handle string-to-typed-value conversions for fields
Expand Down
1 change: 1 addition & 0 deletions crates/sandlock-core/src/sandbox/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ pub struct SandboxBuilder {
// COW fork work function: runs in each COW clone.
#[cfg_attr(feature = "cli", clap(skip))]
pub(crate) work_fn: Option<Arc<dyn Fn(u32) + Send + Sync + 'static>>,

}

impl std::fmt::Debug for SandboxBuilder {
Expand Down
Loading
Loading