From 1f45266517744ea5ce15a9eb6e69612fdbd5929d Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Tue, 6 Oct 2026 03:58:19 +0800 Subject: [PATCH] feat(sandbox): define runtime policy and preflight refusals --- deploy/sandbox.runtime.example.json | 40 ++ judge/README.md | 37 +- judge/crates/sandbox/src/lib.rs | 3 +- judge/crates/sandbox/src/main.rs | 40 ++ judge/crates/sandbox/src/probe.rs | 6 + judge/crates/sandbox/src/runtime.rs | 367 +++++++++++++++++++ judge/crates/sandbox/tests/runtime_cli.rs | 83 +++++ judge/crates/sandbox/tests/runtime_policy.rs | 318 ++++++++++++++++ 8 files changed, 892 insertions(+), 2 deletions(-) create mode 100644 deploy/sandbox.runtime.example.json create mode 100644 judge/crates/sandbox/src/runtime.rs create mode 100644 judge/crates/sandbox/tests/runtime_cli.rs create mode 100644 judge/crates/sandbox/tests/runtime_policy.rs diff --git a/deploy/sandbox.runtime.example.json b/deploy/sandbox.runtime.example.json new file mode 100644 index 0000000..9cf7bde --- /dev/null +++ b/deploy/sandbox.runtime.example.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "state_root": "/var/lib/kf-judge", + "tasks_root": "/var/lib/kf-judge/tasks", + "image_root": "/var/lib/kf-judge/images/gnu-cxx-x86_64-v1", + "image_kind": "linux_gnu_cxx_x86_64_v1", + "delegated_cgroup_root": "/sys/fs/cgroup/kf-judge.service", + "identities": { + "worker_uid": 1000, + "worker_gid": 1000, + "first_task_uid": 100000, + "first_task_gid": 100000, + "count": 2 + }, + "max_concurrent_runs": 2, + "per_task_ceiling": { + "cpu_time_us": 10000000, + "wall_time_us": 30000000, + "memory_bytes": 536870912, + "stack_bytes": 16777216, + "processes": 32, + "open_files": 128, + "stdout_bytes": 1048576, + "stderr_bytes": 1048576, + "file_bytes": 67108864, + "workdir_bytes": 268435456 + }, + "aggregate": { + "memory_bytes": 1610612736, + "supervisor_memory_bytes": 268435456, + "processes": 128, + "supervisor_processes": 32, + "workdir_bytes": 1073741824, + "supervisor_workdir_bytes": 67108864, + "cpu_period_us": 100000, + "cpu_quota_us": 250000, + "task_cpu_quota_us": 100000, + "supervisor_cpu_quota_us": 50000 + } +} diff --git a/judge/README.md b/judge/README.md index bd50105..84107e9 100644 --- a/judge/README.md +++ b/judge/README.md @@ -5,7 +5,7 @@ Cargo workspace 分为五个 crate: | Crate | 职责 | 当前能力 | | --- | --- | --- | | `kf-contracts` | 跨服务通知和执行契约 | 最小 v1 通知、共享 JSON 样例检查 | -| `kf-sandbox` | Linux 隔离、限制、计量与清理 | `probe` 只读观察;`run` 拒绝执行 | +| `kf-sandbox` | Linux 隔离、限制、计量与清理 | 规格/配置检查与 `probe` 只读观察;`run` 拒绝执行 | | `kf-engine` | 语言适配、测试点编排、比较与确定性汇总 | 预留库边界,无模拟判定 | | `kf-worker` | 拉取、租约、工件准备与本地编排 | `--check` 入口,无消费循环 | | `kf-coordinator` | 节点登记、租约、状态与结果写入权 | HTTP 存活检查,无任务端点 | @@ -77,3 +77,38 @@ FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜 超限输出须有对应证据;缺失计量、无说明的截断或不完整读取报告基础设施失败。 工件只能导出规格已申请的路径,单文件与已导出总量均受限;这些字段检查不能替代实际文件安全检查。 准备失败和清理失败分别保留,真实进程树终止、资源回收与 VM 验收仍待后续阶段实现。 + +## 可信运行配置与启动前检查 + +```bash +cd judge +cargo run --locked -p kf-sandbox -- check-runtime --config ../deploy/sandbox.runtime.example.json +``` + +首轮支持策略为 Linux x86_64、内核至少 6.6;这是项目保守支持范围,内核版本不能代替逐项能力验证。 +所需接口包括 user/mount/PID/IPC/UTS/network/cgroup namespace、pidfd 等待、身份降权、seccomp filter, +以及实际委派子树中的 CPU/memory/pids 控制器、`cgroup.kill`、`memory.peak`、内存/进程事件和交换空间限制。 +初始镜像类型固定为 `linux_gnu_cxx_x86_64_v1`:受控只读目录镜像、GNU C/C++ 编译器、动态加载器和 +可信 `manifest.json`;镜像内容、权限和只读挂载验证仍未实现。 + +配置由可信部署方提供,最多 64 KiB;不能由执行请求覆盖。`runtime.rs` 校验受控 state/tasks/image +目录布局、独立 cgroup 子树、普通 Worker 身份和独立任务 UID/GID 槽位;任务身份从 100000 起, +不与配置中的 Worker 重叠。槽位仅定义映射,真正分配前须核查系统账号占用,直到旧任务树为空且 +清理完成后才可复用,服务重启也须恢复租用状态。 + +样例最多并发 2 个任务,每任务上限 512 MiB 内存、32 个进程和 256 MiB 工作空间; +整体内存预算 1.5 GiB、进程预算 128、工作空间预算 1 GiB,分别给监督器保留 256 MiB、32 个进程和 64 MiB。 +CPU 带宽在同一 100 ms 周期中,每任务配额 100 ms、监督器预留 50 ms、父级配额 250 ms。 +校验采用检查过的整数运算,整体预算至少覆盖「任务上限 × 并发数 + 监督器预留」,拒绝溢出或不足。 +CPU 带宽和 CPU 总时间分别配置;预算目前只完成政策校验,真正应用和核验由后续 cgroup/监督器实现。 +工作空间的 tmpfs 占用仍计入任务内存;镜像存储与工件长期保留由部署/工件提供方单独管理。 + +`check-runtime` 只读取当前环境和配置路径,输出结构化拒绝,退出码 2;配置有效时明确 +`configuration_valid: true`、`execution_allowed: false`。目录/接口可见与不可见分别记录, +配置归属、路径真实包含关系、镜像完整性、身份池、helper、委派、预算实际生效、pidfd、namespace、 +降权、seccomp 和清理能力均保留未验证状态。它不创建目录、改账号、启动服务或运行用户程序。 +即使观察值里的 `isolation_verified` 被误设为 true,也不能解除未实现的执行拒绝。 + +接口语义参见 [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)、 +[pidfd_open](https://man7.org/linux/man-pages/man2/pidfd_open.2.html) 和 +[seccomp filter](https://docs.kernel.org/userspace-api/seccomp_filter.html)。 diff --git a/judge/crates/sandbox/src/lib.rs b/judge/crates/sandbox/src/lib.rs index 61f906a..7a2abb8 100644 --- a/judge/crates/sandbox/src/lib.rs +++ b/judge/crates/sandbox/src/lib.rs @@ -1,8 +1,9 @@ -//! Linux execution boundary. Current implementation only provides read-only observations. +//! Linux execution boundary: policy/contract checks and read-only observations. //! //! JS-01 defines execution contracts; JS-02..04 add the launcher, isolation and limits. //! Until that work is verified, the CLI cannot execute any user program. pub mod outcome; pub mod probe; +pub mod runtime; pub mod spec; diff --git a/judge/crates/sandbox/src/main.rs b/judge/crates/sandbox/src/main.rs index 46e1f9e..7810e1e 100644 --- a/judge/crates/sandbox/src/main.rs +++ b/judge/crates/sandbox/src/main.rs @@ -21,6 +21,11 @@ enum Command { #[arg(long)] spec: PathBuf, }, + /// Check trusted runtime policy and report unavailable/unverified capabilities. + CheckRuntime { + #[arg(long)] + config: PathBuf, + }, /// Reserved execution entrypoint; refuses execution until isolation is implemented. Run, } @@ -44,6 +49,7 @@ fn main() -> ExitCode { ExitCode::from(2) } Command::Validate { spec } => validate_spec(spec), + Command::CheckRuntime { config } => check_runtime(config), } } @@ -91,3 +97,37 @@ fn spec_error(code: &str, details: Option) -> ExitCode { eprintln!("{}", serde_json::json!({"error": code, "details": details})); ExitCode::from(2) } + +fn check_runtime(path: PathBuf) -> ExitCode { + let Ok(file) = File::open(path) else { + return spec_error("cannot_read_runtime_config", None); + }; + let mut bytes = Vec::new(); + if file + .take(kf_sandbox::runtime::MAX_CONFIG_BYTES + 1) + .read_to_end(&mut bytes) + .is_err() + { + return spec_error("cannot_read_runtime_config", None); + } + if bytes.len() as u64 > kf_sandbox::runtime::MAX_CONFIG_BYTES { + return spec_error("runtime_config_too_large", None); + } + let request: kf_sandbox::runtime::RuntimeConfig = match serde_json::from_slice(&bytes) { + Ok(request) => request, + Err(error) => { + return spec_error( + "invalid_runtime_config_json", + Some(serde_json::json!({"line":error.line(), "column":error.column()})), + ); + } + }; + match request.validate() { + Ok(config) => { + let report = kf_sandbox::runtime::preflight(&config, &kf_sandbox::probe::observe()); + eprintln!("{}", serde_json::json!(report)); + ExitCode::from(2) + } + Err(error) => spec_error("invalid_runtime_config", Some(serde_json::json!(error))), + } +} diff --git a/judge/crates/sandbox/src/probe.rs b/judge/crates/sandbox/src/probe.rs index 45dea35..7aad8a2 100644 --- a/judge/crates/sandbox/src/probe.rs +++ b/judge/crates/sandbox/src/probe.rs @@ -4,6 +4,8 @@ use std::fs; #[derive(Debug, Serialize)] pub struct EnvironmentObservation { pub os: &'static str, + pub architecture: &'static str, + pub kernel_release: Option, pub namespace_entries: Vec, pub cgroup_v2_visible: bool, pub cgroup_v2_controllers: Vec, @@ -40,6 +42,10 @@ pub fn observe() -> EnvironmentObservation { }); EnvironmentObservation { os: std::env::consts::OS, + architecture: std::env::consts::ARCH, + kernel_release: fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|release| release.trim().to_owned()), namespace_entries, cgroup_v2_visible, cgroup_v2_controllers, diff --git a/judge/crates/sandbox/src/runtime.rs b/judge/crates/sandbox/src/runtime.rs new file mode 100644 index 0000000..9d69070 --- /dev/null +++ b/judge/crates/sandbox/src/runtime.rs @@ -0,0 +1,367 @@ +//! Trusted operator configuration and read-only refusal diagnostics. +//! Validation does not provision directories, reserve identities, apply a +//! resource budget or authenticate the configuration file's ownership. + +use crate::probe::EnvironmentObservation; +use crate::spec::{ResourceLimits, SpecPolicy}; +use kf_contracts::SchemaVersion; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Initial support policy, not a claim that version alone proves capability. +pub const MIN_KERNEL: (u16, u16) = (6, 6); +pub const MAX_CONFIG_BYTES: u64 = 65_536; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImageKind { + #[serde(rename = "linux_gnu_cxx_x86_64_v1")] + LinuxGnuCxxX86_64V1, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityPool { + pub worker_uid: u32, + pub worker_gid: u32, + pub first_task_uid: u32, + pub first_task_gid: u32, + pub count: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AggregateBudget { + pub memory_bytes: u64, + pub supervisor_memory_bytes: u64, + pub processes: u32, + pub supervisor_processes: u32, + pub workdir_bytes: u64, + pub supervisor_workdir_bytes: u64, + pub cpu_period_us: u64, + pub cpu_quota_us: u64, + pub task_cpu_quota_us: u64, + pub supervisor_cpu_quota_us: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfig { + pub schema_version: SchemaVersion, + pub state_root: String, + pub tasks_root: String, + pub image_root: String, + pub image_kind: ImageKind, + pub delegated_cgroup_root: String, + pub identities: IdentityPool, + pub max_concurrent_runs: u32, + pub per_task_ceiling: ResourceLimits, + pub aggregate: AggregateBudget, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +pub struct ConfigError { + pub field: &'static str, + pub code: &'static str, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.field, self.code) + } +} + +impl std::error::Error for ConfigError {} + +fn invalid(field: &'static str, code: &'static str) -> ConfigError { + ConfigError { field, code } +} + +/// Immutable structural policy; deliberately not an execution permit. +#[derive(Debug)] +pub struct ValidatedRuntimeConfig(RuntimeConfig); + +impl ValidatedRuntimeConfig { + pub fn config(&self) -> &RuntimeConfig { + &self.0 + } + + pub fn spec_policy(&self) -> SpecPolicy { + SpecPolicy::with_limits_ceiling(self.0.per_task_ceiling.clone()) + .expect("validated immutable limits ceiling") + } + + /// Slot mapping only; the supervisor must lease slots until the old task + /// tree is empty and cleanup is complete, including after restart. + pub fn task_identity(&self, slot: u32) -> Option<(u32, u32)> { + (slot < self.0.max_concurrent_runs).then(|| { + ( + self.0.identities.first_task_uid + slot, + self.0.identities.first_task_gid + slot, + ) + }) + } +} + +fn canonical_absolute(value: &str) -> bool { + value.starts_with('/') + && value.len() <= 4096 + && value[1..].split('/').all(|part| { + !part.is_empty() + && part != "." + && part != ".." + && part.len() <= 255 + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + }) +} + +fn inside(path: &str, root: &str) -> bool { + path.strip_prefix(root) + .is_some_and(|tail| tail.starts_with('/')) +} + +fn fits(each: u64, slots: u32, reserve: u64, total: u64) -> bool { + each.checked_mul(u64::from(slots)) + .and_then(|sum| sum.checked_add(reserve)) + .is_some_and(|required| required <= total) +} + +impl RuntimeConfig { + pub fn validate(self) -> Result { + for (field, value) in [ + ("state_root", &self.state_root), + ("tasks_root", &self.tasks_root), + ("image_root", &self.image_root), + ("delegated_cgroup_root", &self.delegated_cgroup_root), + ] { + if !canonical_absolute(value) { + return Err(invalid(field, "unsafe_path")); + } + } + if !(inside(&self.state_root, "/var/lib") || inside(&self.state_root, "/srv")) + || !inside(&self.tasks_root, &self.state_root) + || !inside(&self.image_root, &self.state_root) + || self.tasks_root == self.image_root + || inside(&self.tasks_root, &self.image_root) + || inside(&self.image_root, &self.tasks_root) + { + return Err(invalid("roots", "invalid_layout")); + } + if !inside(&self.delegated_cgroup_root, "/sys/fs/cgroup") { + return Err(invalid("delegated_cgroup_root", "not_delegated_subtree")); + } + if !(1..=32).contains(&self.max_concurrent_runs) { + return Err(invalid("max_concurrent_runs", "out_of_range")); + } + let ids = &self.identities; + if ids.worker_uid < 1000 + || ids.worker_gid < 1000 + || ids.worker_uid == u32::MAX + || ids.worker_gid == u32::MAX + || ids.first_task_uid < 100_000 + || ids.first_task_gid < 100_000 + || ids.count < self.max_concurrent_runs + || ids.count > 32 + { + return Err(invalid("identities", "invalid_pool")); + } + for (first, worker) in [ + (ids.first_task_uid, ids.worker_uid), + (ids.first_task_gid, ids.worker_gid), + ] { + let Some(end) = first.checked_add(ids.count - 1) else { + return Err(invalid("identities", "integer_overflow")); + }; + if end == u32::MAX || (first..=end).contains(&worker) { + return Err(invalid("identities", "overlapping_or_reserved_identity")); + } + } + SpecPolicy::with_limits_ceiling(self.per_task_ceiling.clone()) + .map_err(|error| invalid(error.field, "invalid_task_ceiling"))?; + let budget = &self.aggregate; + if budget.supervisor_memory_bytes < 64 * 1024 * 1024 + || budget.supervisor_processes < 8 + || budget.supervisor_workdir_bytes < 1024 * 1024 + || !(1000..=1_000_000).contains(&budget.cpu_period_us) + || budget.task_cpu_quota_us < 1000 + || budget.supervisor_cpu_quota_us < 1000 + { + return Err(invalid("aggregate", "invalid_reservation")); + } + for (field, each, reserve, total) in [ + ( + "aggregate.memory_bytes", + self.per_task_ceiling.memory_bytes, + budget.supervisor_memory_bytes, + budget.memory_bytes, + ), + ( + "aggregate.processes", + u64::from(self.per_task_ceiling.processes), + u64::from(budget.supervisor_processes), + u64::from(budget.processes), + ), + ( + "aggregate.workdir_bytes", + self.per_task_ceiling.workdir_bytes, + budget.supervisor_workdir_bytes, + budget.workdir_bytes, + ), + ( + "aggregate.cpu_quota_us", + budget.task_cpu_quota_us, + budget.supervisor_cpu_quota_us, + budget.cpu_quota_us, + ), + ] { + if !fits(each, self.max_concurrent_runs, reserve, total) { + return Err(invalid(field, "insufficient_or_overflowing_budget")); + } + } + // Conservative first-deployment ceilings, not unlimited host settings. + if budget.memory_bytes > 64 * 1024 * 1024 * 1024 + || budget.workdir_bytes > 16 * 1024 * 1024 * 1024 + || budget.processes > 8192 + || budget.cpu_quota_us > budget.cpu_period_us * 64 + { + return Err(invalid("aggregate", "exceeds_service_ceiling")); + } + Ok(ValidatedRuntimeConfig(self)) + } +} + +#[derive(Debug, Serialize)] +pub struct RefusalReason { + pub code: &'static str, + pub subject: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct RuntimePreflight { + pub error: &'static str, + pub configuration_valid: bool, + pub execution_allowed: bool, + pub reasons: Vec, +} + +pub fn kernel_supported(release: &str) -> Option { + let mut parts = release.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + Some((major, minor) >= MIN_KERNEL) +} + +/// Read-only observations. Every privileged capability remains unverified; +/// even a hypothetical complete filesystem does not authorize execution. +pub fn preflight( + config: &ValidatedRuntimeConfig, + observation: &EnvironmentObservation, +) -> RuntimePreflight { + let mut reasons = Vec::new(); + let mut refuse = |code, subject| reasons.push(RefusalReason { code, subject }); + if observation.os != "linux" { + refuse("unsupported_platform", "os"); + } + if observation.architecture != "x86_64" { + refuse("unsupported_platform", "architecture"); + } + match observation + .kernel_release + .as_deref() + .and_then(kernel_supported) + { + Some(true) => {} + Some(false) => refuse("kernel_below_policy_minimum", "kernel"), + None => refuse("kernel_unobservable", "kernel"), + } + for namespace in ["user", "mnt", "pid", "ipc", "uts", "net", "cgroup"] { + if !observation + .namespace_entries + .iter() + .any(|entry| entry == namespace) + { + refuse("namespace_not_visible", namespace); + } + } + if !observation.cgroup_v2_visible { + refuse("cgroup_v2_not_visible", "cgroup"); + } + for controller in ["cpu", "memory", "pids"] { + if !observation + .cgroup_v2_controllers + .iter() + .any(|entry| entry == controller) + { + refuse("controller_not_visible", controller); + } + } + let policy = config.config(); + for (subject, root) in [ + ("state_root", &policy.state_root), + ("tasks_root", &policy.tasks_root), + ("image_root", &policy.image_root), + ("delegated_cgroup_root", &policy.delegated_cgroup_root), + ] { + if !Path::new(root).is_dir() { + refuse("directory_not_visible", subject); + } + } + for interface in [ + "cgroup.type", + "cgroup.subtree_control", + "cgroup.kill", + "cgroup.events", + "cpu.max", + "cpu.stat", + "memory.max", + "memory.swap.max", + "memory.peak", + "memory.events", + "memory.oom.group", + "pids.max", + "pids.events", + ] { + if !Path::new(&policy.delegated_cgroup_root) + .join(interface) + .is_file() + { + refuse("cgroup_interface_not_visible", interface); + } + } + // A controlled directory image includes a GNU C/C++ toolchain, runtime + // loader and immutable manifest. Content/ownership checks come later. + for (subject, relative) in [ + ("image_manifest", "manifest.json"), + ("gcc", "usr/bin/gcc"), + ("g++", "usr/bin/g++"), + ("dynamic_loader", "lib64/ld-linux-x86-64.so.2"), + ] { + if !Path::new(&policy.image_root).join(relative).is_file() { + refuse("image_file_not_visible", subject); + } + } + for subject in [ + "configuration_ownership", + "directory_containment", + "image_integrity", + "identity_pool_ownership", + "helper_privileges", + "cgroup_delegation", + "aggregate_budget_enforcement", + "pidfd_wait", + "namespace_setup", + "identity_drop", + "seccomp_filter", + "cleanup", + ] { + refuse("capability_not_verified", subject); + } + refuse("sandbox_not_implemented", "isolated_execution"); + RuntimePreflight { + error: "runtime_not_ready", + configuration_valid: true, + execution_allowed: false, + reasons, + } +} diff --git a/judge/crates/sandbox/tests/runtime_cli.rs b/judge/crates/sandbox/tests/runtime_cli.rs new file mode 100644 index 0000000..7ec015b --- /dev/null +++ b/judge/crates/sandbox/tests/runtime_cli.rs @@ -0,0 +1,83 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU32, Ordering}; + +const EXAMPLE: &str = include_str!("../../../../deploy/sandbox.runtime.example.json"); +static SEQUENCE: AtomicU32 = AtomicU32::new(0); + +struct ConfigFile(PathBuf); +impl ConfigFile { + fn new(contents: &[u8]) -> Self { + let path = std::env::temp_dir().join(format!( + "kf-runtime-{}-{}.json", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .unwrap(); + file.write_all(contents).unwrap(); + Self(path) + } + fn check(&self) -> serde_json::Value { + let output = Command::new(env!("CARGO_BIN_EXE_kf-sandbox")) + .args(["check-runtime", "--config"]) + .arg(&self.0) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + serde_json::from_slice(&output.stderr).unwrap() + } +} +impl Drop for ConfigFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +#[test] +fn runtime_cli_reads_example_but_reports_unverified_capabilities_and_refuses() { + let report = ConfigFile::new(EXAMPLE.as_bytes()).check(); + assert_eq!(report["configuration_valid"], true); + assert_eq!(report["execution_allowed"], false); + assert_eq!(report["error"], "runtime_not_ready"); + assert!( + report["reasons"] + .as_array() + .unwrap() + .iter() + .any(|reason| reason["code"] == "sandbox_not_implemented") + ); +} + +#[test] +fn runtime_config_size_boundary_and_invalid_json_are_structured_without_echoing_content() { + let mut exact = EXAMPLE.as_bytes().to_vec(); + exact.resize(kf_sandbox::runtime::MAX_CONFIG_BYTES as usize, b' '); + assert_eq!(ConfigFile::new(&exact).check()["configuration_valid"], true); + exact.push(b' '); + assert_eq!( + ConfigFile::new(&exact).check()["error"], + "runtime_config_too_large" + ); + let report = ConfigFile::new(br#"{"secret":"PRIVATE-CONFIG", "broken":}"#).check(); + assert_eq!(report["error"], "invalid_runtime_config_json"); + assert!(!report.to_string().contains("PRIVATE-CONFIG")); +} + +#[test] +fn runtime_cli_rejects_invalid_policy_and_missing_configuration() { + let mut invalid: serde_json::Value = serde_json::from_str(EXAMPLE).unwrap(); + invalid["identities"]["worker_uid"] = serde_json::json!(0); + let report = ConfigFile::new(serde_json::to_string(&invalid).unwrap().as_bytes()).check(); + assert_eq!(report["error"], "invalid_runtime_config"); + assert_eq!(report["details"]["field"], "identities"); + let missing = ConfigFile::new(EXAMPLE.as_bytes()); + fs::remove_file(&missing.0).unwrap(); + assert_eq!(missing.check()["error"], "cannot_read_runtime_config"); +} diff --git a/judge/crates/sandbox/tests/runtime_policy.rs b/judge/crates/sandbox/tests/runtime_policy.rs new file mode 100644 index 0000000..d090a5b --- /dev/null +++ b/judge/crates/sandbox/tests/runtime_policy.rs @@ -0,0 +1,318 @@ +use kf_sandbox::probe::EnvironmentObservation; +use kf_sandbox::runtime::{RuntimeConfig, kernel_supported, preflight}; +use kf_sandbox::spec::{ExecutionSpec, SpecPolicy}; + +fn config() -> RuntimeConfig { + serde_json::from_str(include_str!( + "../../../../deploy/sandbox.runtime.example.json" + )) + .unwrap() +} + +#[test] +fn example_has_distinct_identity_slots_and_applies_operator_task_ceiling() { + let validated = config().validate().unwrap(); + assert_eq!(validated.task_identity(0), Some((100000, 100000))); + assert_eq!(validated.task_identity(1), Some((100001, 100001))); + assert_eq!(validated.task_identity(2), None); + assert_eq!(validated.task_identity(u32::MAX), None); + let mut request: ExecutionSpec = + serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap(); + request.limits.memory_bytes = 1024 * 1024 * 1024; + assert!(request.clone().validate(&SpecPolicy::default()).is_ok()); + assert!(request.validate(&validated.spec_policy()).is_err()); + let json = serde_json::to_string(validated.config()).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + *validated.config() + ); +} + +#[test] +fn roots_reject_traversal_aliases_system_locations_and_overlapping_storage() { + for path in [ + "/", + "/var/lib/../kf-judge", + "/var//lib/kf-judge", + "/var/lib/kf-judge/", + "relative", + "/var/lib/a\\b", + "/etc/kf-judge", + ] { + let mut policy = config(); + policy.state_root = path.into(); + assert!(policy.validate().is_err(), "{path}"); + } + for path in [ + "/var/lib/kf-judge/tasks", + "/var/lib/kf-judge/tasks/image", + "/var/lib/kf-judge", + "/srv/unrelated", + ] { + let mut policy = config(); + policy.image_root = path.into(); + assert!(policy.validate().is_err(), "{path}"); + } + for path in [ + "/sys/fs/cgroup", + "/sys/fs/cgroup2/job", + "/tmp/cgroup", + "/sys/fs/cgroup/../job", + ] { + let mut policy = config(); + policy.delegated_cgroup_root = path.into(); + assert!(policy.validate().is_err(), "{path}"); + } +} + +#[test] +fn identity_pools_reject_root_collision_shortage_and_integer_overflow() { + let invalid = [ + { + let mut p = config(); + p.identities.worker_uid = 0; + p + }, + { + let mut p = config(); + p.identities.worker_gid = u32::MAX; + p + }, + { + let mut p = config(); + p.identities.count = 0; + p + }, + { + let mut p = config(); + p.identities.count = 1; + p + }, + { + let mut p = config(); + p.identities.first_task_uid = 65534; + p + }, + { + let mut p = config(); + p.identities.worker_uid = 100001; + p + }, + { + let mut p = config(); + p.identities.first_task_gid = u32::MAX; + p + }, + { + let mut p = config(); + p.identities.first_task_uid = u32::MAX - 1; + p + }, + { + let mut p = config(); + p.max_concurrent_runs = 33; + p + }, + ]; + for policy in invalid { + assert!(policy.validate().is_err()); + } +} + +#[test] +fn every_aggregate_reservation_must_cover_all_slots_and_supervision() { + let invalid = [ + { + let mut p = config(); + p.aggregate.memory_bytes = p.per_task_ceiling.memory_bytes * 2; + p + }, + { + let mut p = config(); + p.aggregate.processes = p.per_task_ceiling.processes * 2; + p + }, + { + let mut p = config(); + p.aggregate.workdir_bytes = p.per_task_ceiling.workdir_bytes * 2; + p + }, + { + let mut p = config(); + p.aggregate.cpu_quota_us = p.aggregate.task_cpu_quota_us * 2; + p + }, + { + let mut p = config(); + p.aggregate.supervisor_memory_bytes = 0; + p + }, + { + let mut p = config(); + p.aggregate.supervisor_processes = 0; + p + }, + { + let mut p = config(); + p.aggregate.supervisor_workdir_bytes = 0; + p + }, + { + let mut p = config(); + p.aggregate.supervisor_cpu_quota_us = 0; + p + }, + ]; + for policy in invalid { + assert!(policy.validate().is_err()); + } + let mut exact = config(); + exact.aggregate.cpu_quota_us = + exact.aggregate.task_cpu_quota_us * 2 + exact.aggregate.supervisor_cpu_quota_us; + assert!(exact.validate().is_ok()); +} + +#[test] +fn overflow_huge_service_budgets_and_invalid_task_ceilings_are_rejected() { + let invalid = [ + { + let mut p = config(); + p.aggregate.task_cpu_quota_us = u64::MAX; + p.aggregate.cpu_quota_us = u64::MAX; + p + }, + { + let mut p = config(); + p.aggregate.supervisor_memory_bytes = u64::MAX; + p.aggregate.memory_bytes = u64::MAX; + p + }, + { + let mut p = config(); + p.aggregate.memory_bytes = 64 * 1024 * 1024 * 1024 + 1; + p + }, + { + let mut p = config(); + p.aggregate.processes = 8193; + p + }, + { + let mut p = config(); + p.aggregate.cpu_period_us = 0; + p + }, + { + let mut p = config(); + p.per_task_ceiling.processes = 0; + p + }, + { + let mut p = config(); + p.per_task_ceiling.memory_bytes = 3 * 1024 * 1024 * 1024; + p + }, + ]; + for policy in invalid { + assert!(policy.validate().is_err()); + } +} + +#[test] +fn kernel_policy_handles_release_suffixes_future_versions_and_unknown_values() { + for (release, expected) in [ + ("6.6.0", Some(true)), + ("6.5.99", Some(false)), + ("5.19.0", Some(false)), + ("7.2.9-1-cachyos", Some(true)), + ("999.1.0", Some(true)), + ("unknown", None), + ("6.x.1", None), + ("6", None), + ] { + assert_eq!(kernel_supported(release), expected, "{release}"); + } +} + +#[test] +fn visible_interfaces_or_a_caller_isolation_flag_never_grant_execution() { + let observation = EnvironmentObservation { + os: "linux", + architecture: "x86_64", + kernel_release: Some("7.2.9-1-cachyos".into()), + namespace_entries: ["user", "mnt", "pid", "ipc", "uts", "net", "cgroup"] + .map(String::from) + .to_vec(), + cgroup_v2_visible: true, + cgroup_v2_controllers: ["cpu", "memory", "pids"].map(String::from).to_vec(), + current_process_seccomp_mode: Some(2), + isolation_verified: true, + }; + let report = preflight(&config().validate().unwrap(), &observation); + assert!(!report.execution_allowed); + assert!(report.configuration_valid); + assert!( + report + .reasons + .iter() + .any(|reason| reason.code == "sandbox_not_implemented") + ); + for subject in [ + "cgroup_delegation", + "pidfd_wait", + "seccomp_filter", + "aggregate_budget_enforcement", + ] { + assert!(report.reasons.iter().any(|reason| reason.code=="capability_not_verified" && reason.subject==subject)); + } +} + +#[test] +fn missing_or_unsupported_platform_capabilities_have_structured_reasons() { + let observation = EnvironmentObservation { + os: "windows", + architecture: "aarch64", + kernel_release: Some("6.5.0".into()), + namespace_entries: vec![], + cgroup_v2_visible: false, + cgroup_v2_controllers: vec![], + current_process_seccomp_mode: None, + isolation_verified: false, + }; + let report = preflight(&config().validate().unwrap(), &observation); + for (code, subject) in [ + ("unsupported_platform", "os"), + ("unsupported_platform", "architecture"), + ("kernel_below_policy_minimum", "kernel"), + ("namespace_not_visible", "net"), + ("cgroup_v2_not_visible", "cgroup"), + ("controller_not_visible", "memory"), + ] { + assert!( + report + .reasons + .iter() + .any(|reason| reason.code == code && reason.subject == subject) + ); + } +} + +#[test] +fn configuration_cannot_enable_execution_or_bypass_required_fields() { + let valid = serde_json::to_value(config()).unwrap(); + let mut invalid = valid.clone(); + invalid["execution_enabled"] = serde_json::json!(true); + assert!(serde_json::from_value::(invalid).is_err()); + for (pointer, value) in [ + ("/schema_version", serde_json::json!(2)), + ("/image_kind", serde_json::json!("host")), + ("/max_concurrent_runs", serde_json::json!(4294967296_u64)), + ] { + let mut invalid = valid.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert!(serde_json::from_value::(invalid).is_err()); + } + let mut invalid = valid; + invalid.as_object_mut().unwrap().remove("aggregate"); + assert!(serde_json::from_value::(invalid).is_err()); +}