Files
clawmates/crates/tc-sandbox/tests/security.rs
T
Omar SobhandClaude Fable 5 4f253bec93 P6: browser.goto — real Chromium browsing with live web taint
- SandboxSpec gains an egress flag (default false — the kernel suite
  still proves zero-network for agent sandboxes). Egress-enabled
  containers exist ONLY for the browser: no credentials, no broker
  route, bridge network with host-gateway alias for local test pages
- images/agent-browser: Alpine Chromium, uid 10001, setuid bits
  stripped — same non-root hardening as agent-base
- browser.goto tool: headless chromium --dump-dom in the agent's
  browser container; HTML stripped to readable text (4k cap) and
  returned with output_taint=web; viewport screenshot captured,
  base64'd out of the container, stored in the blob store
- Taint semantics tightened: the step that PRODUCED untrusted output
  now carries its own taint (recorded before the step row), not just
  later steps — chat.inbox test updated to the stricter §15 reading
- GET /api/claws/{id}/browser/viewport.png serves the latest capture;
  BrowserApp polls it and renders the live viewport (spec §7.1),
  keeping the empty state until the agent has browsed
- Proven end to end with REAL Chromium against a REAL local page:
  content 'Revenue up 14 percent' returned tainted web; the gated
  email.send that follows carries 'web' in its approval taint_sources
  (untrusted content can never quietly reach outward); screenshot
  verified by PNG magic bytes

152 Rust tests + 63 frontend + 27 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 09:41:40 -05:00

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 tc_sandbox::{DockerDriver, SandboxDriver, SandboxSpec};
const IMAGE: &str = "teamclaw/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, tc_sandbox::SandboxHandle) {
ensure_image();
let driver = DockerDriver::connect().expect("docker daemon reachable");
let spec = SandboxSpec {
name: format!("teamclaw-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());
}