Files
clawmates/crates/tc-sandbox/tests/k8s_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

148 lines
5.0 KiB
Rust

//! The K8s sandbox driver against a REAL kind cluster: the same kernel
//! probes as the Docker suite (uid, capabilities, no-new-privs, rootfs),
//! plus namespace policy objects. Run with `--features k8s-tests` after
//! `kind create cluster --name teamclaw-test` and a `kind load` of the
//! agent image (the harness does both image steps itself).
#![cfg(feature = "k8s-tests")]
use std::process::Command;
use tc_sandbox::{K8sDriver, SandboxDriver, SandboxSpec};
const IMAGE: &str = "teamclaw/agent-base:dev";
const CLUSTER: &str = "teamclaw-test";
const NAMESPACE: &str = "teamclaw-sandboxes-test";
fn ensure_image_in_kind() {
let exists = Command::new("docker")
.args(["image", "inspect", IMAGE])
.output()
.expect("docker available")
.status
.success();
if !exists {
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");
}
let status = Command::new("kind")
.args(["load", "docker-image", IMAGE, "--name", CLUSTER])
.status()
.expect("kind available");
assert!(status.success(), "kind load failed");
}
async fn spawn(suffix: &str) -> (K8sDriver, tc_sandbox::SandboxHandle) {
ensure_image_in_kind();
let driver = K8sDriver::connect(NAMESPACE)
.await
.expect("cluster reachable");
let spec = SandboxSpec {
name: format!("tc-k8s-{suffix}-{}", std::process::id()),
image: IMAGE.into(),
memory_bytes: 256 * 1024 * 1024,
nano_cpus: 1_000_000_000,
pids_limit: 128,
egress: false,
};
let handle = driver.provision(&spec).await.expect("pod provisions");
(driver, handle)
}
#[tokio::test]
async fn pod_runs_hardened_with_writable_scratch_only() {
let (driver, handle) = spawn("hard").await;
let uid = driver.exec(&handle, &["id", "-u"]).await.unwrap();
assert_eq!(uid.stdout.trim(), "10001", "stderr: {}", uid.stderr);
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);
let nnp = driver
.exec(&handle, &["grep", "NoNewPrivs", "/proc/self/status"])
.await
.unwrap();
assert!(nnp.stdout.trim().ends_with('1'), "got: {}", nnp.stdout);
let write_root = driver
.exec(&handle, &["touch", "/etc/owned"])
.await
.unwrap();
assert_ne!(write_root.exit_code, 0, "rootfs must reject writes");
let write_tmp = driver
.exec(&handle, &["touch", "/tmp/scratch"])
.await
.unwrap();
assert_eq!(write_tmp.exit_code, 0, "stderr: {}", write_tmp.stderr);
let write_home = driver
.exec(&handle, &["touch", "/home/agent/file"])
.await
.unwrap();
assert_eq!(write_home.exit_code, 0, "stderr: {}", write_home.stderr);
driver.destroy(&handle).await.unwrap();
}
#[tokio::test]
async fn namespace_carries_pss_restricted_and_default_deny_policy() {
let (driver, handle) = spawn("policy").await;
// Assert through the API: PSS label + the deny-all NetworkPolicy.
// (Kernel-level egress enforcement needs a NetworkPolicy-capable CNI;
// kind's default kindnet does not enforce — production clusters do.)
let client = kube::Client::try_default().await.unwrap();
let namespaces: kube::Api<k8s_openapi::api::core::v1::Namespace> =
kube::Api::all(client.clone());
let ns = namespaces.get(NAMESPACE).await.unwrap();
assert_eq!(
ns.metadata
.labels
.unwrap()
.get("pod-security.kubernetes.io/enforce")
.map(String::as_str),
Some("restricted")
);
let policies: kube::Api<k8s_openapi::api::networking::v1::NetworkPolicy> =
kube::Api::namespaced(client, NAMESPACE);
let deny = policies.get("sandbox-default-deny").await.unwrap();
let spec = deny.spec.unwrap();
assert_eq!(
spec.policy_types.unwrap(),
vec!["Ingress".to_owned(), "Egress".to_owned()]
);
assert!(spec.ingress.is_none() && spec.egress.is_none(), "deny-all");
driver.destroy(&handle).await.unwrap();
}
#[tokio::test]
async fn destroy_removes_the_pod_and_health_reflects_it() {
let (driver, handle) = spawn("life").await;
assert!(driver.health(&handle).await.unwrap());
driver.destroy(&handle).await.unwrap();
for _ in 0..60 {
if !driver.health(&handle).await.unwrap() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
panic!("pod never disappeared");
}