Full-depth rename per the approved plan; the 'claw' product vocabulary (claws, /claws routes, clawId, Claw Chat) stays — it is now the brand. - Display brand: Clawmates (manifest, titles, hero, login/rail logo 'clawmates'); default host app.clawmates.work; registry ghcr.io/clawmates - Crates tc-* -> cm-* (16 crates + all imports); binaries clawmates-server/broker/bundler; images clawmates/*; env prefix CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config clawmates.toml; helm chart deploy/helm/clawmates with clawmates-* resources; db names clawmates*; sockets /run/clawmates; cookie cm_session; kind cluster clawmates-test; seccomp node profile clawmates-agent-profile.json - All 9 Playwright brand assertions updated in lockstep; historical spec document left untouched as the only remaining 'TeamClaw' - Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared test server clawmates-test-pg, kind cluster recreated with image + profile, compose images rebuilt under clawmates/* Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and the clean-room install rehearsal serving the clawmates login page from a signed bundle of the rebuilt images. Co-Authored-By: Claude Fable 5 <[email protected]>
194 lines
5.9 KiB
Rust
194 lines
5.9 KiB
Rust
//! Kernel-level sandbox assertions (spec §15, acceptance-blocking): these
|
|
//! tests spawn REAL containers and probe the controls from inside —
|
|
//! asserting kernel behavior, not configuration strings.
|
|
|
|
use std::process::Command;
|
|
|
|
use cm_sandbox::{DockerDriver, SandboxDriver, SandboxSpec};
|
|
|
|
const IMAGE: &str = "clawmates/agent-base:dev";
|
|
|
|
/// Builds the agent image once if missing (subsequent runs hit the cache).
|
|
fn ensure_image() {
|
|
let exists = Command::new("docker")
|
|
.args(["image", "inspect", IMAGE])
|
|
.output()
|
|
.expect("docker available")
|
|
.status
|
|
.success();
|
|
if exists {
|
|
return;
|
|
}
|
|
let root = env!("CARGO_MANIFEST_DIR");
|
|
let status = Command::new("docker")
|
|
.args([
|
|
"build",
|
|
"-t",
|
|
IMAGE,
|
|
"-f",
|
|
&format!("{root}/../../images/agent-base/Dockerfile"),
|
|
&format!("{root}/../../images/agent-base"),
|
|
])
|
|
.status()
|
|
.expect("docker build runs");
|
|
assert!(status.success(), "agent-base image build failed");
|
|
}
|
|
|
|
async fn spawn(name_suffix: &str) -> (DockerDriver, cm_sandbox::SandboxHandle) {
|
|
ensure_image();
|
|
let driver = DockerDriver::connect().expect("docker daemon reachable");
|
|
let spec = SandboxSpec {
|
|
name: format!("clawmates-test-sbx-{name_suffix}-{}", std::process::id()),
|
|
image: IMAGE.into(),
|
|
memory_bytes: 256 * 1024 * 1024,
|
|
nano_cpus: 1_000_000_000,
|
|
pids_limit: 128,
|
|
egress: false,
|
|
};
|
|
// Clean leftovers from interrupted runs, then provision fresh.
|
|
let _ = driver.destroy_by_name(&spec.name).await;
|
|
let handle = driver.provision(&spec).await.expect("sandbox provisions");
|
|
(driver, handle)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runs_as_uid_10001_with_no_capabilities() {
|
|
let (driver, handle) = spawn("uid").await;
|
|
|
|
let uid = driver.exec(&handle, &["id", "-u"]).await.unwrap();
|
|
assert_eq!(uid.stdout.trim(), "10001", "stderr: {}", uid.stderr);
|
|
|
|
// CapEff all-zero proves cap-drop ALL took effect in the kernel.
|
|
let caps = driver
|
|
.exec(&handle, &["grep", "CapEff", "/proc/self/status"])
|
|
.await
|
|
.unwrap();
|
|
let value = caps.stdout.split_whitespace().last().unwrap_or("");
|
|
assert_eq!(
|
|
u64::from_str_radix(value, 16).unwrap(),
|
|
0,
|
|
"effective capabilities must be empty, got {value}"
|
|
);
|
|
|
|
driver.destroy(&handle).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rootfs_is_read_only_with_writable_tmp() {
|
|
let (driver, handle) = spawn("rootfs").await;
|
|
|
|
let write_root = driver
|
|
.exec(&handle, &["touch", "/etc/owned"])
|
|
.await
|
|
.unwrap();
|
|
assert_ne!(write_root.exit_code, 0, "rootfs must reject writes");
|
|
assert!(
|
|
write_root.stderr.contains("Read-only file system"),
|
|
"got: {}",
|
|
write_root.stderr
|
|
);
|
|
|
|
let write_tmp = driver
|
|
.exec(&handle, &["touch", "/tmp/scratch"])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(write_tmp.exit_code, 0, "tmpfs /tmp must be writable");
|
|
|
|
let write_home = driver
|
|
.exec(&handle, &["touch", "/home/agent/file"])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(write_home.exit_code, 0, "workdir must be writable");
|
|
|
|
driver.destroy(&handle).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn seccomp_denies_namespace_and_tracing_syscalls() {
|
|
let (driver, handle) = spawn("seccomp").await;
|
|
|
|
// unshare(2) is denied by the profile even though no capability is
|
|
// required for a user-namespace unshare.
|
|
let unshare = driver
|
|
.exec(&handle, &["unshare", "--user", "true"])
|
|
.await
|
|
.unwrap();
|
|
assert_ne!(unshare.exit_code, 0, "unshare must be denied");
|
|
|
|
driver.destroy(&handle).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_has_no_network_path_at_all() {
|
|
let (driver, handle) = spawn("egress").await;
|
|
|
|
// network=none: no traffic-carrying interface exists. The kernel
|
|
// auto-creates inert DOWN tunnel devices (tunl0, gre0, sit0, ...) in
|
|
// every namespace; what must be absent is any ethernet/veth device.
|
|
let interfaces = driver
|
|
.exec(&handle, &["cat", "/proc/net/dev"])
|
|
.await
|
|
.unwrap();
|
|
let carriers: Vec<&str> = interfaces
|
|
.stdout
|
|
.lines()
|
|
.skip(2)
|
|
.map(str::trim_start)
|
|
.filter(|line| {
|
|
line.starts_with("eth") || line.starts_with("en") || line.starts_with("veth")
|
|
})
|
|
.collect();
|
|
assert!(carriers.is_empty(), "unexpected interfaces: {carriers:?}");
|
|
|
|
// And an actual connect attempt goes nowhere.
|
|
let connect = driver
|
|
.exec(
|
|
&handle,
|
|
&[
|
|
"timeout",
|
|
"3",
|
|
"bash",
|
|
"-c",
|
|
"echo probe > /dev/tcp/1.1.1.1/443",
|
|
],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_ne!(connect.exit_code, 0, "egress connect must fail");
|
|
|
|
driver.destroy(&handle).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn no_setuid_binaries_exist_and_privileges_cannot_grow() {
|
|
let (driver, handle) = spawn("setuid").await;
|
|
|
|
let setuid = driver
|
|
.exec(
|
|
&handle,
|
|
&[
|
|
"find", "/usr", "/bin", "/sbin", "-perm", "/6000", "-type", "f",
|
|
],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(setuid.stdout.trim(), "", "setuid binaries found");
|
|
|
|
// NoNewPrivs flag is set on every process (no-new-privileges).
|
|
let nnp = driver
|
|
.exec(&handle, &["grep", "NoNewPrivs", "/proc/self/status"])
|
|
.await
|
|
.unwrap();
|
|
assert!(nnp.stdout.trim().ends_with('1'), "got: {}", nnp.stdout);
|
|
|
|
driver.destroy(&handle).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn destroy_removes_the_container_and_health_reflects_it() {
|
|
let (driver, handle) = spawn("lifecycle").await;
|
|
assert!(driver.health(&handle).await.unwrap());
|
|
driver.destroy(&handle).await.unwrap();
|
|
assert!(!driver.health(&handle).await.unwrap());
|
|
}
|