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:
co-authored by
Claude Fable 5
parent
5407111a89
commit
e65bcd4130
@@ -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:
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -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 |
Executable
+41
@@ -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
|
||||||
Reference in New Issue
Block a user