Post-1.0 finale: Calico-enforced egress denial + visual-regression lock

- scripts/netpol-cluster.sh: a kind cluster with Calico (default CNI
  disabled) — the only way to PROVE the §15 default-deny NetworkPolicy,
  since kindnet accepts the object but never enforces it. New live test
  on that cluster: outbound connect to 1.1.1.1 dropped, DNS egress
  dropped, while API-server exec keeps working (not pod network).
  Kernel-level enforcement of the sandbox egress claim, demonstrated
- K8sDriver::connect_with_context: pin a kubeconfig context instead of
  ambient. The whole k8s suite now pins its cluster explicitly — the
  netpol cluster's creation had silently switched the current context
  and stranded the seccomp test on the wrong cluster (fixed and made
  impossible to recur)
- CI: netpol-cluster up + calico egress test in the sandbox-k8s job
- Visual-regression lock (plan P6): @visual Playwright spec with
  animation-disabled, masked-dynamic-region screenshots of login,
  workspace home, chat welcome, computer panel, credits; darwin
  baselines committed (5 PNGs); CI excludes @visual until linux
  baselines are generated there. Full local suite: 33 journeys

165 Rust tests + 5 live kind tests (2 clusters) + 33 journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 13:30:10 -05:00
co-authored by Claude Fable 5
parent 5407111a89
commit e65bcd4130
11 changed files with 214 additions and 4 deletions
+5 -1
View File
@@ -51,6 +51,10 @@ jobs:
cluster_name: clawmates-test cluster_name: clawmates-test
- name: K8s sandbox kernel assertions - name: K8s sandbox kernel assertions
run: cargo test -p cm-sandbox --features k8s-tests --test k8s_security run: cargo test -p cm-sandbox --features k8s-tests --test k8s_security
- name: Calico cluster (NetworkPolicy enforcement)
run: ./scripts/netpol-cluster.sh up
- name: Egress default-deny enforced in the kernel
run: cargo test -p cm-sandbox --features k8s-tests --test k8s_security calico
frontend: frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -96,7 +100,7 @@ jobs:
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
- name: Run end-to-end journeys against the real backend - name: Run end-to-end journeys against the real backend
working-directory: frontend working-directory: frontend
run: npx playwright test run: npx playwright test --grep-invert "@visual"
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: failure() if: failure()
with: with:
+22
View File
@@ -42,6 +42,28 @@ impl K8sDriver {
// are linked; first caller wins, repeats are harmless. // are linked; first caller wins, repeats are harmless.
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let client = kube::Client::try_default().await.map_err(engine_err)?; let client = kube::Client::try_default().await.map_err(engine_err)?;
K8sDriver::with_client(client, namespace).await
}
/// Connects to a SPECIFIC kubeconfig context (e.g. the
/// NetworkPolicy-enforcing test cluster) instead of the current one.
pub async fn connect_with_context(
namespace: &str,
context: &str,
) -> Result<K8sDriver, SandboxError> {
let _ = rustls::crypto::ring::default_provider().install_default();
let options = kube::config::KubeConfigOptions {
context: Some(context.to_owned()),
..Default::default()
};
let config = kube::Config::from_kubeconfig(&options)
.await
.map_err(engine_err)?;
let client = kube::Client::try_from(config).map_err(engine_err)?;
K8sDriver::with_client(client, namespace).await
}
async fn with_client(client: kube::Client, namespace: &str) -> Result<K8sDriver, SandboxError> {
let driver = K8sDriver { let driver = K8sDriver {
client, client,
namespace: namespace.to_owned(), namespace: namespace.to_owned(),
+76 -3
View File
@@ -11,6 +11,7 @@ use cm_sandbox::{K8sDriver, SandboxDriver, SandboxSpec};
const IMAGE: &str = "clawmates/agent-base:dev"; const IMAGE: &str = "clawmates/agent-base:dev";
const CLUSTER: &str = "clawmates-test"; const CLUSTER: &str = "clawmates-test";
const CONTEXT: &str = "kind-clawmates-test";
const NAMESPACE: &str = "clawmates-sandboxes-test"; const NAMESPACE: &str = "clawmates-sandboxes-test";
fn ensure_image_in_kind() { fn ensure_image_in_kind() {
@@ -42,9 +43,18 @@ fn ensure_image_in_kind() {
assert!(status.success(), "kind load failed"); assert!(status.success(), "kind load failed");
} }
fn ensure_image_in_cluster(cluster: &str) {
ensure_image_in_kind();
let status = Command::new("kind")
.args(["load", "docker-image", IMAGE, "--name", cluster])
.status()
.expect("kind available");
assert!(status.success(), "kind load into {cluster} failed");
}
async fn spawn(suffix: &str) -> (K8sDriver, cm_sandbox::SandboxHandle) { async fn spawn(suffix: &str) -> (K8sDriver, cm_sandbox::SandboxHandle) {
ensure_image_in_kind(); ensure_image_in_kind();
let driver = K8sDriver::connect(NAMESPACE) let driver = K8sDriver::connect_with_context(NAMESPACE, CONTEXT)
.await .await
.expect("cluster reachable"); .expect("cluster reachable");
let spec = SandboxSpec { let spec = SandboxSpec {
@@ -106,7 +116,12 @@ async fn namespace_carries_pss_restricted_and_default_deny_policy() {
// Assert through the API: PSS label + the deny-all NetworkPolicy. // Assert through the API: PSS label + the deny-all NetworkPolicy.
// (Kernel-level egress enforcement needs a NetworkPolicy-capable CNI; // (Kernel-level egress enforcement needs a NetworkPolicy-capable CNI;
// kind's default kindnet does not enforce — production clusters do.) // kind's default kindnet does not enforce — production clusters do.)
let client = kube::Client::try_default().await.unwrap(); let options = kube::config::KubeConfigOptions {
context: Some(CONTEXT.to_owned()),
..Default::default()
};
let config = kube::Config::from_kubeconfig(&options).await.unwrap();
let client = kube::Client::try_from(config).unwrap();
let namespaces: kube::Api<k8s_openapi::api::core::v1::Namespace> = let namespaces: kube::Api<k8s_openapi::api::core::v1::Namespace> =
kube::Api::all(client.clone()); kube::Api::all(client.clone());
let ns = namespaces.get(NAMESPACE).await.unwrap(); let ns = namespaces.get(NAMESPACE).await.unwrap();
@@ -174,7 +189,7 @@ async fn localhost_seccomp_profile_denies_unshare_inside_pods() {
.expect("docker cp"); .expect("docker cp");
assert!(status.success()); assert!(status.success());
let driver = K8sDriver::connect(NAMESPACE) let driver = K8sDriver::connect_with_context(NAMESPACE, CONTEXT)
.await .await
.expect("cluster reachable") .expect("cluster reachable")
.with_localhost_seccomp("clawmates-agent-profile.json"); .with_localhost_seccomp("clawmates-agent-profile.json");
@@ -202,3 +217,61 @@ async fn localhost_seccomp_profile_denies_unshare_inside_pods() {
driver.destroy(&handle).await.unwrap(); driver.destroy(&handle).await.unwrap();
} }
/// The §15 egress claim, ENFORCED: on a NetworkPolicy-capable CNI
/// (Calico via scripts/netpol-cluster.sh) the namespace's default-deny
/// actually drops packets in the kernel — outbound connects and DNS both
/// fail inside the pod, while API-server exec still works (it is not pod
/// network). kindnet (the default suite's cluster) accepts the policy
/// object but never enforces it; this is the cluster where it bites.
#[tokio::test]
async fn calico_enforces_the_default_deny_egress() {
const NETPOL_CONTEXT: &str = "kind-clawmates-netpol-test";
const NETPOL_CLUSTER: &str = "clawmates-netpol-test";
let have_cluster = Command::new("kind")
.args(["get", "clusters"])
.output()
.map(|out| String::from_utf8_lossy(&out.stdout).contains(NETPOL_CLUSTER))
.unwrap_or(false);
if !have_cluster {
eprintln!("skipped: run scripts/netpol-cluster.sh up first");
return;
}
ensure_image_in_cluster(NETPOL_CLUSTER);
let driver = K8sDriver::connect_with_context(NAMESPACE, NETPOL_CONTEXT)
.await
.expect("calico cluster reachable");
let spec = SandboxSpec {
name: format!("tc-netpol-{}", 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");
// Exec works (API-server channel, not pod network).
let ok = driver.exec(&handle, &["id", "-u"]).await.unwrap();
assert_eq!(ok.stdout.trim(), "10001");
// Raw outbound connect: dropped by Calico, not merely unconfigured.
let direct = driver
.exec(
&handle,
&["wget", "-T", "3", "-q", "-O", "-", "http://1.1.1.1"],
)
.await
.unwrap();
assert_ne!(direct.exit_code, 0, "egress to 1.1.1.1 must be dropped");
// DNS (UDP egress to cluster DNS) is denied too.
let dns = driver
.exec(&handle, &["nslookup", "anthropic.com"])
.await
.unwrap();
assert_ne!(dns.exit_code, 0, "DNS egress must be dropped");
driver.destroy(&handle).await.unwrap();
}
+69
View File
@@ -0,0 +1,69 @@
import { expect, test, type Page } from "@playwright/test";
// Visual-regression lock (plan P6): pixel baselines for the §2 design
// system on the primary surfaces. Baselines are PER-PLATFORM (rendering
// differs across OSes); CI excludes @visual until linux baselines are
// generated there — see ci.yml. Regenerate intentionally with:
// npx playwright test visual --update-snapshots
const OWNER_EMAIL = "[email protected]";
const OWNER_PASSWORD = "e2e-password";
const SCREENSHOT = {
animations: "disabled" as const,
maxDiffPixelRatio: 0.02,
};
async function signIn(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
}
test("login page looks right @visual", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
await expect(page).toHaveScreenshot("login.png", SCREENSHOT);
});
test("workspace home looks right @visual", async ({ page }) => {
await signIn(page);
await expect(page).toHaveScreenshot("workspace-home.png", SCREENSHOT);
});
test("chat welcome and computer panel look right @visual", async ({
page,
}) => {
await signIn(page);
await page.getByRole("link", { name: /Scout/ }).click();
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
const before = page.url();
await page.getByRole("button", { name: "New" }).click();
await page.waitForURL((url) => url.toString() !== before);
await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible();
await expect(page).toHaveScreenshot("chat-welcome.png", SCREENSHOT);
await page.getByRole("button", { name: "Computer" }).click();
const panel = page.getByRole("complementary", { name: "Computer" });
await expect(panel.getByText(/Scout's Computer/)).toBeVisible();
await expect(page).toHaveScreenshot("computer-home.png", SCREENSHOT);
});
test("credits page looks right @visual", async ({ page }) => {
await signIn(page);
await page.getByRole("link", { name: "Credits" }).click();
await expect(page.getByText("Available credits")).toBeVisible();
await expect(page).toHaveScreenshot("credits.png", {
...SCREENSHOT,
// Balance and usage are live numbers — mask them, lock the layout.
mask: [
page.getByTestId("credit-balance"),
page.getByText(/credits ·/),
page.getByText(/days of runway/),
],
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# A kind cluster with a NetworkPolicy-ENFORCING CNI (Calico) for the
# egress-denial e2e: kind's default kindnet accepts NetworkPolicy objects
# but never enforces them, so only this cluster can prove the §15
# default-deny actually drops packets in the kernel.
#
# Usage: scripts/netpol-cluster.sh {up|down}
set -euo pipefail
CLUSTER="clawmates-netpol-test"
CALICO_VERSION="v3.29.1"
case "${1:-up}" in
up)
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
echo "$CLUSTER already exists"
else
kind create cluster --name "$CLUSTER" --wait 120s --config - <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
disableDefaultCNI: true
podSubnet: 192.168.0.0/16
EOF
kubectl --context "kind-$CLUSTER" apply -f \
"https://raw.githubusercontent.com/projectcalico/calico/$CALICO_VERSION/manifests/calico.yaml"
fi
echo "waiting for calico + node readiness..."
kubectl --context "kind-$CLUSTER" -n kube-system rollout status \
daemonset/calico-node --timeout=300s
kubectl --context "kind-$CLUSTER" wait --for=condition=Ready node --all --timeout=120s
echo "$CLUSTER ready (calico enforcing)"
;;
down)
kind delete cluster --name "$CLUSTER"
;;
*)
echo "usage: $0 {up|down}" >&2
exit 1
;;
esac