feat(sandbox): define runtime policy and preflight refusals
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+36
-1
@@ -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)。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<serde_json::Value>) -> 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))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub namespace_entries: Vec<String>,
|
||||
pub cgroup_v2_visible: bool,
|
||||
pub cgroup_v2_controllers: Vec<String>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ValidatedRuntimeConfig, ConfigError> {
|
||||
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<RefusalReason>,
|
||||
}
|
||||
|
||||
pub fn kernel_supported(release: &str) -> Option<bool> {
|
||||
let mut parts = release.split('.');
|
||||
let major = parts.next()?.parse::<u16>().ok()?;
|
||||
let minor = parts.next()?.parse::<u16>().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,
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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::<RuntimeConfig>(&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::<RuntimeConfig>(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::<RuntimeConfig>(invalid).is_err());
|
||||
}
|
||||
let mut invalid = valid;
|
||||
invalid.as_object_mut().unwrap().remove("aggregate");
|
||||
assert!(serde_json::from_value::<RuntimeConfig>(invalid).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user