From 7b42abc6eee2fc1ca13ef8956a1bd54dcc43baaf Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 7 Jul 2026 19:23:01 -0400 Subject: [PATCH 1/5] fix(desktop): provision agent VMs without public NAT Remove ONE_TO_ONE_NAT accessConfigs from GCE insert payload so new agent VMs are private-network only. Add Rust contract test guarding the provision JSON shape. Verification: cd desktop/macos/Backend-Rust && cargo test provision Related #7326 Co-authored-by: Cursor --- .../macos/Backend-Rust/src/routes/agent.rs | 86 ++++++++++++++----- 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/desktop/macos/Backend-Rust/src/routes/agent.rs b/desktop/macos/Backend-Rust/src/routes/agent.rs index ec1488006b8..f1b620e39ae 100644 --- a/desktop/macos/Backend-Rust/src/routes/agent.rs +++ b/desktop/macos/Backend-Rust/src/routes/agent.rs @@ -493,29 +493,23 @@ async fn start_stopped_vm( Ok(ip) } -/// Create a GCE VM from the omi-agent image family. -/// Returns the external IP of the created VM. -async fn create_gce_vm( - firestore: &crate::services::FirestoreService, +/// Build the GCE instances.insert request body for a new agent VM. +/// Agent VMs use private networking only — no public NAT / external IP. +fn build_gce_vm_insert_body( vm_name: &str, auth_token: &str, - project: &str, source_image: &str, gcs_bucket: &str, -) -> Result> { - let zone = "us-central1-a"; - + zone: &str, +) -> serde_json::Value { // Startup script: pull the real startup.sh from GCS and run it. // All logic lives in GCS so it can be updated without reprovisioning VMs. - let startup_script = format!("#!/bin/bash\ncurl -sf https://storage.googleapis.com/{}/startup.sh -o /tmp/omi-startup.sh \\\n && bash /tmp/omi-startup.sh\n", gcs_bucket); - - // GCE instances.insert REST API - let url = format!( - "https://compute.googleapis.com/compute/v1/projects/{}/zones/{}/instances", - project, zone + let startup_script = format!( + "#!/bin/bash\ncurl -sf https://storage.googleapis.com/{}/startup.sh -o /tmp/omi-startup.sh \\\n && bash /tmp/omi-startup.sh\n", + gcs_bucket ); - let body = serde_json::json!({ + serde_json::json!({ "name": vm_name, "machineType": format!("zones/{}/machineTypes/e2-small", zone), "disks": [{ @@ -528,11 +522,7 @@ async fn create_gce_vm( } }], "networkInterfaces": [{ - "network": "global/networks/default", - "accessConfigs": [{ - "type": "ONE_TO_ONE_NAT", - "name": "External NAT" - }] + "network": "global/networks/default" }], "tags": { "items": ["omi-agent-vm"] @@ -546,7 +536,28 @@ async fn create_gce_vm( "value": auth_token }] } - }); + }) +} + +/// Create a GCE VM from the omi-agent image family. +/// Returns the external IP of the created VM. +async fn create_gce_vm( + firestore: &crate::services::FirestoreService, + vm_name: &str, + auth_token: &str, + project: &str, + source_image: &str, + gcs_bucket: &str, +) -> Result> { + let zone = "us-central1-a"; + + // GCE instances.insert REST API + let url = format!( + "https://compute.googleapis.com/compute/v1/projects/{}/zones/{}/instances", + project, zone + ); + + let body = build_gce_vm_insert_body(vm_name, auth_token, source_image, gcs_bucket, zone); // Use Firestore's authenticated request builder (same service account) let response = firestore @@ -621,3 +632,36 @@ pub fn agent_routes() -> Router { .route("/v2/agent/provision", post(provision_agent_vm)) .route("/v2/agent/status", get(get_agent_status)) } + +#[cfg(test)] +mod contract_tests { + use super::build_gce_vm_insert_body; + + #[test] + fn contract_create_gce_vm_provision_json_has_no_public_nat() { + let body = build_gce_vm_insert_body( + "omi-agent-contract", + "omi-test-token", + "projects/test/global/images/family/omi-agent", + "omi-agent-artifacts", + "us-central1-a", + ); + + let network = &body["networkInterfaces"][0]; + assert_eq!(network["network"], "global/networks/default"); + assert!( + network.get("accessConfigs").is_none(), + "agent VM provisioning must not request public NAT" + ); + + let serialized = serde_json::to_string(&body).expect("provision body serializes"); + assert!( + !serialized.contains("ONE_TO_ONE_NAT"), + "provision body must not contain ONE_TO_ONE_NAT" + ); + assert!( + !serialized.contains("External NAT"), + "provision body must not contain External NAT access config" + ); + } +} From 0fdd87097b158e0c5ac07acb151db2711a0305c0 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 7 Jul 2026 19:27:20 -0400 Subject: [PATCH 2/5] fix(security): deny public agent VM port 8080 via firewall IaC Add GCP firewall IaC for tag omi-agent-vm that denies public tcp:8080, with apply/reachability scripts and hermetic contract tests. Extend the Rust provision JSON contract to assert omi-agent-vm tagging and no public IP fields in the insert payload. Verification: cd desktop/macos/Backend-Rust && cargo test provision cd backend && python3 -m pytest tests/unit/test_agent_vm_firewall_contract.py -v backend/scripts/agent-vm-reachability-check.sh Closes #7326 Related #6611 Co-authored-by: Cursor --- AGENTS.md | 1 + .../agent-vm-firewall/firewall-rule.yaml | 27 +++++ .../scripts/agent-vm-reachability-check.sh | 90 +++++++++++++++ backend/scripts/apply-agent-vm-firewall.sh | 106 ++++++++++++++++++ .../unit/test_agent_vm_firewall_contract.py | 70 ++++++++++++ .../macos/Backend-Rust/src/routes/agent.rs | 16 +++ 6 files changed, 310 insertions(+) create mode 100644 backend/charts/agent-vm-firewall/firewall-rule.yaml create mode 100755 backend/scripts/agent-vm-reachability-check.sh create mode 100755 backend/scripts/apply-agent-vm-firewall.sh create mode 100644 backend/tests/unit/test_agent_vm_firewall_contract.py diff --git a/AGENTS.md b/AGENTS.md index 16c0100fe1b..aa568b48b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ Helm charts: `backend/charts/{agent-proxy,agent-vm-reaper,backend-listen,backend - **notifications-job** (`modal/job.py`) — Cron job, reads Firestore/Redis, sends push notifications. - **monitoring** (`backend/charts/monitoring/`) — Prometheus, Grafana, Loki, Alloy, alerts, and HPA metric adapters for backend services. - **agent-vm-reaper** (`backend/charts/agent-vm-reaper/`) — CronJob that deletes stale `omi-agent-*` GCE VMs left by desktop agent sandboxes. +- **agent-vm-firewall** (`backend/charts/agent-vm-firewall/`) — GCP firewall IaC denying public `tcp:8080` to VMs tagged `omi-agent-vm`; apply via `backend/scripts/apply-agent-vm-firewall.sh`, verify via `backend/scripts/agent-vm-reachability-check.sh`. - **backend-secrets** (`backend/charts/backend-secrets/`) — ExternalSecret and SecretStore resources that sync backend runtime secrets into GKE namespaces. Backend runtime env contract: keep `backend/deploy/runtime_env.yaml` aligned with GKE Helm values and Cloud Run runtime env; run `backend/scripts/pre-deploy-check.sh` after backend runtime env or deploy workflow changes. diff --git a/backend/charts/agent-vm-firewall/firewall-rule.yaml b/backend/charts/agent-vm-firewall/firewall-rule.yaml new file mode 100644 index 00000000000..a5713ac9792 --- /dev/null +++ b/backend/charts/agent-vm-firewall/firewall-rule.yaml @@ -0,0 +1,27 @@ +# GCP firewall rule: deny public internet ingress to agent VM port 8080 (#7326). +# +# Agent VMs are tagged `omi-agent-vm` at provision time (desktop Backend-Rust +# build_gce_vm_insert_body). This rule is defense-in-depth when a VM still has +# an external IP or a broad default-allow ingress rule would otherwise reach 8080. +# +# Apply (idempotent): +# backend/scripts/apply-agent-vm-firewall.sh +# +# Verify before desktop-backend deploy: +# backend/scripts/agent-vm-reachability-check.sh +# backend/scripts/agent-vm-reachability-check.sh --live based-hardware +name: omi-agent-vm-deny-public-8080 +project: based-hardware +network: default +priority: 1000 +direction: INGRESS +action: DENY +rules: + - protocol: tcp + ports: + - "8080" +sourceRanges: + - 0.0.0.0/0 +targetTags: + - omi-agent-vm +description: Deny public internet ingress to agent VM HTTP/WS port 8080 (#7326) diff --git a/backend/scripts/agent-vm-reachability-check.sh b/backend/scripts/agent-vm-reachability-check.sh new file mode 100755 index 00000000000..775b13dcd74 --- /dev/null +++ b/backend/scripts/agent-vm-reachability-check.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Hermetic contract check (default) or live GCP verification for agent VM firewall. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RULE_FILE="${ROOT}/charts/agent-vm-firewall/firewall-rule.yaml" + +usage() { + cat <<'EOF' +Usage: backend/scripts/agent-vm-reachability-check.sh [--live PROJECT] + +Hermetic (default): + - validate firewall IaC denies public tcp:8080 for tag omi-agent-vm + +Live (--live, requires gcloud auth): + - describe the applied firewall rule in GCP and assert the same contract +EOF +} + +run_hermetic() { + python3 -m pytest "${ROOT}/tests/unit/test_agent_vm_firewall_contract.py" -q +} + +run_live() { + local project="$1" + python3 - "$RULE_FILE" "$project" <<'PY' +import json +import subprocess +import sys +from pathlib import Path + +import yaml + +rule_file = Path(sys.argv[1]) +project = sys.argv[2] +expected = yaml.safe_load(rule_file.read_text()) +name = expected["name"] + +raw = subprocess.check_output( + [ + "gcloud", + "compute", + "firewall-rules", + "describe", + name, + "--project", + project, + "--format=json", + ], + text=True, +) +live = json.loads(raw) + +assert live.get("direction") == expected["direction"], live.get("direction") +assert live.get("priority") == expected["priority"], live.get("priority") +assert live.get("denied"), "live rule must use denied[] (DENY action)" +denied = live["denied"] +assert any( + entry.get("IPProtocol") == "tcp" and "8080" in (entry.get("ports") or []) + for entry in denied +), denied +assert live.get("sourceRanges") == expected["sourceRanges"], live.get("sourceRanges") +assert live.get("targetTags") == expected["targetTags"], live.get("targetTags") +print(f"live firewall contract ok: {name} (project={project})") +PY +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ "${1:-}" == "--live" ]]; then + if [[ $# -ne 2 ]]; then + echo "ERROR: --live requires GCP_PROJECT_ID" >&2 + usage + exit 2 + fi + run_hermetic + run_live "$2" +else + if [[ $# -ne 0 ]]; then + echo "ERROR: unknown arguments: $*" >&2 + usage + exit 2 + fi + run_hermetic +fi + +echo "agent-vm reachability checks passed" diff --git a/backend/scripts/apply-agent-vm-firewall.sh b/backend/scripts/apply-agent-vm-firewall.sh new file mode 100755 index 00000000000..3ab05fd2a86 --- /dev/null +++ b/backend/scripts/apply-agent-vm-firewall.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Idempotently apply the agent VM public-8080 deny firewall from IaC. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RULE_FILE="${ROOT}/charts/agent-vm-firewall/firewall-rule.yaml" + +if [[ ! -f "$RULE_FILE" ]]; then + echo "ERROR: missing firewall IaC: $RULE_FILE" >&2 + exit 1 +fi + +if ! command -v gcloud >/dev/null 2>&1; then + echo "ERROR: gcloud is required to apply agent VM firewall rules" >&2 + exit 1 +fi + +read_rule() { + python3 - "$RULE_FILE" <<'PY' +import sys +from pathlib import Path + +import yaml + +data = yaml.safe_load(Path(sys.argv[1]).read_text()) +required = ( + "name", + "project", + "network", + "priority", + "direction", + "action", + "rules", + "sourceRanges", + "targetTags", +) +missing = [key for key in required if key not in data] +if missing: + raise SystemExit(f"firewall IaC missing keys: {', '.join(missing)}") +if data["action"] != "DENY": + raise SystemExit("agent VM firewall contract requires action=DENY") +print( + data["name"], + data["project"], + data["network"], + data["priority"], + data["direction"], + data["action"], + ",".join(data["sourceRanges"]), + ",".join(data["targetTags"]), + data.get("description", ""), + sep="\t", +) +PY +} + +IFS=$'\t' read -r NAME PROJECT NETWORK PRIORITY DIRECTION ACTION SOURCE_RANGES TARGET_TAGS DESCRIPTION < <(read_rule) + +RULES_ARGS=() +while IFS= read -r rule; do + RULES_ARGS+=("$rule") +done < <( + python3 - "$RULE_FILE" <<'PY' +import sys +from pathlib import Path + +import yaml + +data = yaml.safe_load(Path(sys.argv[1]).read_text()) +for entry in data["rules"]: + protocol = entry["protocol"] + ports = entry.get("ports") or [] + if ports: + print(f"{protocol}:{','.join(ports)}") + else: + print(protocol) +PY +) + +if gcloud compute firewall-rules describe "$NAME" --project="$PROJECT" >/dev/null 2>&1; then + echo "updating existing firewall rule $NAME (project=$PROJECT)" + gcloud compute firewall-rules update "$NAME" \ + --project="$PROJECT" \ + --network="$NETWORK" \ + --priority="$PRIORITY" \ + --direction="$DIRECTION" \ + --action="$ACTION" \ + --rules="$(IFS=,; echo "${RULES_ARGS[*]}")" \ + --source-ranges="$SOURCE_RANGES" \ + --target-tags="$TARGET_TAGS" \ + ${DESCRIPTION:+--description="$DESCRIPTION"} +else + echo "creating firewall rule $NAME (project=$PROJECT)" + gcloud compute firewall-rules create "$NAME" \ + --project="$PROJECT" \ + --network="$NETWORK" \ + --priority="$PRIORITY" \ + --direction="$DIRECTION" \ + --action="$ACTION" \ + --rules="$(IFS=,; echo "${RULES_ARGS[*]}")" \ + --source-ranges="$SOURCE_RANGES" \ + --target-tags="$TARGET_TAGS" \ + ${DESCRIPTION:+--description="$DESCRIPTION"} +fi + +echo "agent VM firewall applied: $NAME" diff --git a/backend/tests/unit/test_agent_vm_firewall_contract.py b/backend/tests/unit/test_agent_vm_firewall_contract.py new file mode 100644 index 00000000000..aafcd2dd025 --- /dev/null +++ b/backend/tests/unit/test_agent_vm_firewall_contract.py @@ -0,0 +1,70 @@ +"""Hermetic contract tests for agent VM network exposure controls (#7326).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIREWALL_IAC = REPO_ROOT / "backend" / "charts" / "agent-vm-firewall" / "firewall-rule.yaml" +AGENT_RS = REPO_ROOT / "desktop" / "macos" / "Backend-Rust" / "src" / "routes" / "agent.rs" + + +def _load_firewall_contract() -> dict: + return yaml.safe_load(FIREWALL_IAC.read_text()) + + +def test_firewall_iac_denies_public_8080_for_omi_agent_vm_tag(): + rule = _load_firewall_contract() + + assert rule["action"] == "DENY" + assert rule["direction"] == "INGRESS" + assert "0.0.0.0/0" in rule["sourceRanges"] + assert rule["targetTags"] == ["omi-agent-vm"] + + denied_tcp_8080 = any( + entry.get("protocol") == "tcp" and "8080" in (entry.get("ports") or []) for entry in rule["rules"] + ) + assert denied_tcp_8080, "firewall IaC must deny tcp:8080 from the public internet" + + +def _provision_insert_body_source() -> str: + source = AGENT_RS.read_text() + body_fn = source.split("fn build_gce_vm_insert_body", 1)[1] + return body_fn.split("\n/// Create a GCE VM", 1)[0] + + +def test_provision_rust_contract_test_guards_public_ip_exposure(): + """Provision payload contract lives in agent.rs — keep IaC and code linked.""" + source = AGENT_RS.read_text() + insert_body = _provision_insert_body_source() + + assert "fn build_gce_vm_insert_body" in source + assert "contract_create_gce_vm_provision_json_has_no_public_nat" in source + assert '"items": ["omi-agent-vm"]' in insert_body + assert "ONE_TO_ONE_NAT" not in insert_body + assert "accessConfigs" not in insert_body + + +@pytest.mark.parametrize( + "needle", + [ + "natIP", + "ONE_TO_ONE_NAT", + "External NAT", + "accessConfigs", + ], +) +def test_provision_fixture_json_has_no_public_ip_fields(needle: str): + """Serialized GCE insert body must not request or embed public IP fields.""" + fixture = { + "name": "omi-agent-contract", + "machineType": "zones/us-central1-a/machineTypes/e2-small", + "networkInterfaces": [{"network": "global/networks/default"}], + "tags": {"items": ["omi-agent-vm"]}, + } + serialized = json.dumps(fixture) + assert needle not in serialized diff --git a/desktop/macos/Backend-Rust/src/routes/agent.rs b/desktop/macos/Backend-Rust/src/routes/agent.rs index f1b620e39ae..72a1a3d5472 100644 --- a/desktop/macos/Backend-Rust/src/routes/agent.rs +++ b/desktop/macos/Backend-Rust/src/routes/agent.rs @@ -654,6 +654,14 @@ mod contract_tests { "agent VM provisioning must not request public NAT" ); + let tags = body["tags"]["items"] + .as_array() + .expect("provision body must include network tags"); + assert!( + tags.iter().any(|tag| tag == "omi-agent-vm"), + "provision body must tag VMs for omi-agent-vm firewall policy" + ); + let serialized = serde_json::to_string(&body).expect("provision body serializes"); assert!( !serialized.contains("ONE_TO_ONE_NAT"), @@ -663,5 +671,13 @@ mod contract_tests { !serialized.contains("External NAT"), "provision body must not contain External NAT access config" ); + assert!( + !serialized.contains("natIP"), + "provision body must not expose public IP fields" + ); + assert!( + !serialized.contains("accessConfigs"), + "provision body must not expose accessConfigs" + ); } } From d77624e36a8c093a6973dabb1117f614e6e1c867 Mon Sep 17 00:00:00 2001 From: David Zhang <9387252+Git-on-my-level@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:02:17 +0700 Subject: [PATCH 3/5] fix(agent): keep private VM connectivity without NAT --- AGENTS.md | 2 +- .../agent-vm-firewall/firewall-rule.yaml | 54 ++++-- .../scripts/agent-vm-reachability-check.sh | 84 +++++---- backend/scripts/apply-agent-vm-firewall.sh | 168 +++++++++++------- .../unit/test_agent_vm_firewall_contract.py | 40 +++-- .../macos/Backend-Rust/src/routes/agent.rs | 65 +++++-- 6 files changed, 270 insertions(+), 143 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa568b48b90..114216b59bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ Helm charts: `backend/charts/{agent-proxy,agent-vm-reaper,backend-listen,backend - **notifications-job** (`modal/job.py`) — Cron job, reads Firestore/Redis, sends push notifications. - **monitoring** (`backend/charts/monitoring/`) — Prometheus, Grafana, Loki, Alloy, alerts, and HPA metric adapters for backend services. - **agent-vm-reaper** (`backend/charts/agent-vm-reaper/`) — CronJob that deletes stale `omi-agent-*` GCE VMs left by desktop agent sandboxes. -- **agent-vm-firewall** (`backend/charts/agent-vm-firewall/`) — GCP firewall IaC denying public `tcp:8080` to VMs tagged `omi-agent-vm`; apply via `backend/scripts/apply-agent-vm-firewall.sh`, verify via `backend/scripts/agent-vm-reachability-check.sh`. +- **agent-vm-firewall** (`backend/charts/agent-vm-firewall/`) — GCP firewall IaC allowing private `tcp:8080` before denying public `tcp:8080` to VMs tagged `omi-agent-vm`; apply via `backend/scripts/apply-agent-vm-firewall.sh`, verify via `backend/scripts/agent-vm-reachability-check.sh`. - **backend-secrets** (`backend/charts/backend-secrets/`) — ExternalSecret and SecretStore resources that sync backend runtime secrets into GKE namespaces. Backend runtime env contract: keep `backend/deploy/runtime_env.yaml` aligned with GKE Helm values and Cloud Run runtime env; run `backend/scripts/pre-deploy-check.sh` after backend runtime env or deploy workflow changes. diff --git a/backend/charts/agent-vm-firewall/firewall-rule.yaml b/backend/charts/agent-vm-firewall/firewall-rule.yaml index a5713ac9792..ffc83e2d681 100644 --- a/backend/charts/agent-vm-firewall/firewall-rule.yaml +++ b/backend/charts/agent-vm-firewall/firewall-rule.yaml @@ -1,8 +1,8 @@ -# GCP firewall rule: deny public internet ingress to agent VM port 8080 (#7326). +# GCP firewall rules for agent VM port 8080 exposure control (#7326). # # Agent VMs are tagged `omi-agent-vm` at provision time (desktop Backend-Rust -# build_gce_vm_insert_body). This rule is defense-in-depth when a VM still has -# an external IP or a broad default-allow ingress rule would otherwise reach 8080. +# build_gce_vm_insert_body). Keep the private allow rule higher priority than +# the public deny rule: a 0.0.0.0/0 DENY also matches internal VPC sources. # # Apply (idempotent): # backend/scripts/apply-agent-vm-firewall.sh @@ -10,18 +10,36 @@ # Verify before desktop-backend deploy: # backend/scripts/agent-vm-reachability-check.sh # backend/scripts/agent-vm-reachability-check.sh --live based-hardware -name: omi-agent-vm-deny-public-8080 -project: based-hardware -network: default -priority: 1000 -direction: INGRESS -action: DENY -rules: - - protocol: tcp - ports: - - "8080" -sourceRanges: - - 0.0.0.0/0 -targetTags: - - omi-agent-vm -description: Deny public internet ingress to agent VM HTTP/WS port 8080 (#7326) +firewallRules: + - name: omi-agent-vm-allow-private-8080 + project: based-hardware + network: default + priority: 900 + direction: INGRESS + action: ALLOW + rules: + - protocol: tcp + ports: + - "8080" + sourceRanges: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + targetTags: + - omi-agent-vm + description: Allow private VPC ingress from agent-proxy to agent VM HTTP/WS port 8080 (#7326) + - name: omi-agent-vm-deny-public-8080 + project: based-hardware + network: default + priority: 1000 + direction: INGRESS + action: DENY + rules: + - protocol: tcp + ports: + - "8080" + sourceRanges: + - 0.0.0.0/0 + targetTags: + - omi-agent-vm + description: Deny public internet ingress to agent VM HTTP/WS port 8080 (#7326) diff --git a/backend/scripts/agent-vm-reachability-check.sh b/backend/scripts/agent-vm-reachability-check.sh index 775b13dcd74..f484d5d1437 100755 --- a/backend/scripts/agent-vm-reachability-check.sh +++ b/backend/scripts/agent-vm-reachability-check.sh @@ -10,10 +10,10 @@ usage() { Usage: backend/scripts/agent-vm-reachability-check.sh [--live PROJECT] Hermetic (default): - - validate firewall IaC denies public tcp:8080 for tag omi-agent-vm + - validate firewall IaC allows private tcp:8080 before denying public tcp:8080 Live (--live, requires gcloud auth): - - describe the applied firewall rule in GCP and assert the same contract + - describe the applied firewall rules in GCP and enforce the same contract EOF } @@ -33,35 +33,57 @@ import yaml rule_file = Path(sys.argv[1]) project = sys.argv[2] -expected = yaml.safe_load(rule_file.read_text()) -name = expected["name"] - -raw = subprocess.check_output( - [ - "gcloud", - "compute", - "firewall-rules", - "describe", - name, - "--project", - project, - "--format=json", - ], - text=True, -) -live = json.loads(raw) - -assert live.get("direction") == expected["direction"], live.get("direction") -assert live.get("priority") == expected["priority"], live.get("priority") -assert live.get("denied"), "live rule must use denied[] (DENY action)" -denied = live["denied"] -assert any( - entry.get("IPProtocol") == "tcp" and "8080" in (entry.get("ports") or []) - for entry in denied -), denied -assert live.get("sourceRanges") == expected["sourceRanges"], live.get("sourceRanges") -assert live.get("targetTags") == expected["targetTags"], live.get("targetTags") -print(f"live firewall contract ok: {name} (project={project})") +expected_rules = yaml.safe_load(rule_file.read_text())["firewallRules"] + + +def fail(message: str) -> None: + raise SystemExit(f"live firewall contract failed: {message}") + + +def expect_equal(name: str, field: str, actual, expected) -> None: + if actual != expected: + fail(f"{name}: {field} expected {expected!r}, got {actual!r}") + + +def expect_tcp_8080(name: str, live_rule: dict) -> None: + entries = live_rule.get("allowed") or live_rule.get("denied") or [] + if not any(entry.get("IPProtocol") == "tcp" and "8080" in (entry.get("ports") or []) for entry in entries): + fail(f"{name}: missing tcp:8080 rule in allowed/denied entries: {entries!r}") + + +def describe(name: str) -> dict: + raw = subprocess.check_output( + [ + "gcloud", + "compute", + "firewall-rules", + "describe", + name, + "--project", + project, + "--format=json", + ], + text=True, + ) + return json.loads(raw) + +for expected in expected_rules: + name = expected["name"] + live = describe(name) + expect_equal(name, "direction", live.get("direction"), expected["direction"]) + expect_equal(name, "priority", live.get("priority"), expected["priority"]) + expect_equal(name, "sourceRanges", live.get("sourceRanges"), expected["sourceRanges"]) + expect_equal(name, "targetTags", live.get("targetTags"), expected["targetTags"]) + if expected["action"] == "ALLOW": + if not live.get("allowed"): + fail(f"{name}: live rule must use allowed[] (ALLOW action)") + elif expected["action"] == "DENY": + if not live.get("denied"): + fail(f"{name}: live rule must use denied[] (DENY action)") + else: + fail(f"{name}: unsupported action {expected['action']!r}") + expect_tcp_8080(name, live) +print(f"live firewall contract ok: {len(expected_rules)} rules (project={project})") PY } diff --git a/backend/scripts/apply-agent-vm-firewall.sh b/backend/scripts/apply-agent-vm-firewall.sh index 3ab05fd2a86..1540c810af2 100755 --- a/backend/scripts/apply-agent-vm-firewall.sh +++ b/backend/scripts/apply-agent-vm-firewall.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Idempotently apply the agent VM public-8080 deny firewall from IaC. +# Idempotently apply the agent VM port-8080 firewall rules from IaC. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -15,15 +15,15 @@ if ! command -v gcloud >/dev/null 2>&1; then exit 1 fi -read_rule() { +read_rules() { python3 - "$RULE_FILE" <<'PY' +import json import sys from pathlib import Path import yaml -data = yaml.safe_load(Path(sys.argv[1]).read_text()) -required = ( +RULE_REQUIRED = ( "name", "project", "network", @@ -34,73 +34,113 @@ required = ( "sourceRanges", "targetTags", ) -missing = [key for key in required if key not in data] -if missing: - raise SystemExit(f"firewall IaC missing keys: {', '.join(missing)}") -if data["action"] != "DENY": - raise SystemExit("agent VM firewall contract requires action=DENY") -print( - data["name"], - data["project"], - data["network"], - data["priority"], - data["direction"], - data["action"], - ",".join(data["sourceRanges"]), - ",".join(data["targetTags"]), - data.get("description", ""), - sep="\t", -) -PY -} +PRIVATE_RANGES = {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} -IFS=$'\t' read -r NAME PROJECT NETWORK PRIORITY DIRECTION ACTION SOURCE_RANGES TARGET_TAGS DESCRIPTION < <(read_rule) -RULES_ARGS=() -while IFS= read -r rule; do - RULES_ARGS+=("$rule") -done < <( - python3 - "$RULE_FILE" <<'PY' -import sys -from pathlib import Path +def _rules(data: dict) -> list[dict]: + rules = data.get("firewallRules") + if not isinstance(rules, list) or not rules: + raise SystemExit("firewall IaC must define non-empty firewallRules[]") + return rules + + +def _has_tcp_8080(rule: dict) -> bool: + return any( + entry.get("protocol") == "tcp" and "8080" in (entry.get("ports") or []) + for entry in rule.get("rules", []) + ) + + +def _validate(rule: dict) -> None: + missing = [key for key in RULE_REQUIRED if key not in rule] + if missing: + raise SystemExit(f"firewall IaC rule missing keys: {', '.join(missing)}") + if rule["direction"] != "INGRESS": + raise SystemExit(f"{rule['name']}: agent VM firewall contract requires direction=INGRESS") + if "omi-agent-vm" not in rule["targetTags"]: + raise SystemExit(f"{rule['name']}: targetTags must include omi-agent-vm") + if not _has_tcp_8080(rule): + raise SystemExit(f"{rule['name']}: rules must include tcp:8080") + + +def _validate_contract(rules: list[dict]) -> None: + for rule in rules: + _validate(rule) + allow_private = [rule for rule in rules if rule["name"] == "omi-agent-vm-allow-private-8080"] + deny_public = [rule for rule in rules if rule["name"] == "omi-agent-vm-deny-public-8080"] + if len(allow_private) != 1 or len(deny_public) != 1: + raise SystemExit("firewall IaC must define one private ALLOW and one public DENY rule") + allow = allow_private[0] + deny = deny_public[0] + if allow["action"] != "ALLOW": + raise SystemExit("private rule must use action=ALLOW") + if deny["action"] != "DENY": + raise SystemExit("public rule must use action=DENY") + if not PRIVATE_RANGES.issubset(set(allow["sourceRanges"])): + raise SystemExit("private allow rule must include RFC1918 source ranges") + if "0.0.0.0/0" not in deny["sourceRanges"]: + raise SystemExit("public deny rule must include 0.0.0.0/0") + if int(allow["priority"]) >= int(deny["priority"]): + raise SystemExit("private allow rule priority must be higher than public deny priority") -import yaml data = yaml.safe_load(Path(sys.argv[1]).read_text()) -for entry in data["rules"]: +rules = _rules(data) +_validate_contract(rules) +for rule in rules: + print(json.dumps(rule, separators=(",", ":"))) +PY +} + +rule_args() { + python3 - "$1" <<'PY' +import json +import sys + +rule = json.loads(sys.argv[1]) +args = [] +for entry in rule["rules"]: protocol = entry["protocol"] ports = entry.get("ports") or [] - if ports: - print(f"{protocol}:{','.join(ports)}") - else: - print(protocol) + args.append(f"{protocol}:{','.join(ports)}" if ports else protocol) +print(",".join(args)) PY -) +} -if gcloud compute firewall-rules describe "$NAME" --project="$PROJECT" >/dev/null 2>&1; then - echo "updating existing firewall rule $NAME (project=$PROJECT)" - gcloud compute firewall-rules update "$NAME" \ - --project="$PROJECT" \ - --network="$NETWORK" \ - --priority="$PRIORITY" \ - --direction="$DIRECTION" \ - --action="$ACTION" \ - --rules="$(IFS=,; echo "${RULES_ARGS[*]}")" \ - --source-ranges="$SOURCE_RANGES" \ - --target-tags="$TARGET_TAGS" \ - ${DESCRIPTION:+--description="$DESCRIPTION"} -else - echo "creating firewall rule $NAME (project=$PROJECT)" - gcloud compute firewall-rules create "$NAME" \ - --project="$PROJECT" \ - --network="$NETWORK" \ - --priority="$PRIORITY" \ - --direction="$DIRECTION" \ - --action="$ACTION" \ - --rules="$(IFS=,; echo "${RULES_ARGS[*]}")" \ - --source-ranges="$SOURCE_RANGES" \ - --target-tags="$TARGET_TAGS" \ - ${DESCRIPTION:+--description="$DESCRIPTION"} -fi +while IFS= read -r RULE_JSON; do + NAME=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["name"])' "$RULE_JSON") + PROJECT=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["project"])' "$RULE_JSON") + NETWORK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["network"])' "$RULE_JSON") + PRIORITY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["priority"])' "$RULE_JSON") + DIRECTION=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["direction"])' "$RULE_JSON") + ACTION=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["action"])' "$RULE_JSON") + SOURCE_RANGES=$(python3 -c 'import json,sys; print(",".join(json.loads(sys.argv[1])["sourceRanges"]))' "$RULE_JSON") + TARGET_TAGS=$(python3 -c 'import json,sys; print(",".join(json.loads(sys.argv[1])["targetTags"]))' "$RULE_JSON") + DESCRIPTION=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("description", ""))' "$RULE_JSON") + RULES_CSV=$(rule_args "$RULE_JSON") + + GCLOUD_ARGS=( + "$NAME" + --project="$PROJECT" + --network="$NETWORK" + --priority="$PRIORITY" + --direction="$DIRECTION" + --action="$ACTION" + --rules="$RULES_CSV" + --source-ranges="$SOURCE_RANGES" + --target-tags="$TARGET_TAGS" + ) + if [[ -n "$DESCRIPTION" ]]; then + GCLOUD_ARGS+=(--description="$DESCRIPTION") + fi + + if gcloud compute firewall-rules describe "$NAME" --project="$PROJECT" >/dev/null 2>&1; then + echo "updating existing firewall rule $NAME (project=$PROJECT)" + gcloud compute firewall-rules update "${GCLOUD_ARGS[@]}" + else + echo "creating firewall rule $NAME (project=$PROJECT)" + gcloud compute firewall-rules create "${GCLOUD_ARGS[@]}" + fi +done < <(read_rules) -echo "agent VM firewall applied: $NAME" +echo "agent VM firewall applied: $RULE_FILE" diff --git a/backend/tests/unit/test_agent_vm_firewall_contract.py b/backend/tests/unit/test_agent_vm_firewall_contract.py index aafcd2dd025..7e40de4c74a 100644 --- a/backend/tests/unit/test_agent_vm_firewall_contract.py +++ b/backend/tests/unit/test_agent_vm_firewall_contract.py @@ -13,22 +13,37 @@ AGENT_RS = REPO_ROOT / "desktop" / "macos" / "Backend-Rust" / "src" / "routes" / "agent.rs" -def _load_firewall_contract() -> dict: - return yaml.safe_load(FIREWALL_IAC.read_text()) +def _load_firewall_rules() -> list[dict]: + return yaml.safe_load(FIREWALL_IAC.read_text())["firewallRules"] -def test_firewall_iac_denies_public_8080_for_omi_agent_vm_tag(): - rule = _load_firewall_contract() +def _rule(name: str) -> dict: + matches = [rule for rule in _load_firewall_rules() if rule["name"] == name] + assert len(matches) == 1, f"expected exactly one firewall rule named {name}" + return matches[0] - assert rule["action"] == "DENY" - assert rule["direction"] == "INGRESS" - assert "0.0.0.0/0" in rule["sourceRanges"] - assert rule["targetTags"] == ["omi-agent-vm"] - denied_tcp_8080 = any( - entry.get("protocol") == "tcp" and "8080" in (entry.get("ports") or []) for entry in rule["rules"] - ) - assert denied_tcp_8080, "firewall IaC must deny tcp:8080 from the public internet" +def _denies_tcp_8080(rule: dict) -> bool: + return any(entry.get("protocol") == "tcp" and "8080" in (entry.get("ports") or []) for entry in rule["rules"]) + + +def test_firewall_iac_allows_private_8080_before_public_deny_for_omi_agent_vm_tag(): + allow = _rule("omi-agent-vm-allow-private-8080") + deny = _rule("omi-agent-vm-deny-public-8080") + + assert allow["action"] == "ALLOW" + assert allow["direction"] == "INGRESS" + assert set(allow["sourceRanges"]) >= {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} + assert allow["targetTags"] == ["omi-agent-vm"] + assert _denies_tcp_8080(allow), "private firewall IaC must allow tcp:8080 from private ranges" + + assert deny["action"] == "DENY" + assert deny["direction"] == "INGRESS" + assert "0.0.0.0/0" in deny["sourceRanges"] + assert deny["targetTags"] == ["omi-agent-vm"] + assert _denies_tcp_8080(deny), "firewall IaC must deny tcp:8080 from the public internet" + + assert allow["priority"] < deny["priority"], "private allow must outrank the 0.0.0.0/0 deny" def _provision_insert_body_source() -> str: @@ -44,6 +59,7 @@ def test_provision_rust_contract_test_guards_public_ip_exposure(): assert "fn build_gce_vm_insert_body" in source assert "contract_create_gce_vm_provision_json_has_no_public_nat" in source + assert "contract_agent_vm_ip_uses_private_network_ip" in source assert '"items": ["omi-agent-vm"]' in insert_body assert "ONE_TO_ONE_NAT" not in insert_body assert "accessConfigs" not in insert_body diff --git a/desktop/macos/Backend-Rust/src/routes/agent.rs b/desktop/macos/Backend-Rust/src/routes/agent.rs index 72a1a3d5472..2937b0097b6 100644 --- a/desktop/macos/Backend-Rust/src/routes/agent.rs +++ b/desktop/macos/Backend-Rust/src/routes/agent.rs @@ -16,6 +16,14 @@ fn local_harness_agent_disabled() -> bool { std::env::var("ENVIRONMENT").as_deref() == Ok("local-dev-harness") } +fn extract_private_vm_ip(instance: &serde_json::Value) -> Result { + instance["networkInterfaces"][0]["networkIP"] + .as_str() + .filter(|ip| !ip.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| "GCE instance response missing private networkIP".to_string()) +} + /// POST /v2/agent/provision /// Idempotent — if user already has a VM, returns existing info. /// Creates a GCE VM from the omi-agent image family for this user. @@ -329,11 +337,17 @@ async fn get_agent_status( { if let Ok(resp) = resp.send().await { if let Ok(instance) = resp.json::().await { - let ip = instance["networkInterfaces"][0]["accessConfigs"] - [0]["natIP"] - .as_str() - .unwrap_or("unknown") - .to_string(); + let ip = match extract_private_vm_ip(&instance) { + Ok(ip) => ip, + Err(e) => { + tracing::warn!( + "Could not recover private IP for VM {}: {}", + vm_name, + e + ); + return; + } + }; let now = chrono::Utc::now().to_rfc3339(); let _ = firestore .set_agent_vm( @@ -472,7 +486,8 @@ async fn start_stopped_vm( } } - // Get the VM's (possibly new) external IP + // Get the VM's (possibly new) private VPC IP. Agent VMs intentionally have + // no public NAT, and agent-proxy reaches them over private networking. let instance_url = format!( "https://compute.googleapis.com/compute/v1/projects/{}/zones/{}/instances/{}", project, zone, vm_name @@ -485,10 +500,7 @@ async fn start_stopped_vm( .await?; let instance: serde_json::Value = instance_resp.json().await?; - let ip = instance["networkInterfaces"][0]["accessConfigs"][0]["natIP"] - .as_str() - .unwrap_or("unknown") - .to_string(); + let ip = extract_private_vm_ip(&instance)?; Ok(ip) } @@ -540,7 +552,7 @@ fn build_gce_vm_insert_body( } /// Create a GCE VM from the omi-agent image family. -/// Returns the external IP of the created VM. +/// Returns the private VPC IP of the created VM. async fn create_gce_vm( firestore: &crate::services::FirestoreService, vm_name: &str, @@ -602,7 +614,8 @@ async fn create_gce_vm( } } - // Get the VM's external IP + // Get the VM's private VPC IP. There is no accessConfigs/natIP when the VM + // is provisioned without a public NAT. let instance_url = format!( "https://compute.googleapis.com/compute/v1/projects/{}/zones/{}/instances/{}", project, zone, vm_name @@ -615,10 +628,7 @@ async fn create_gce_vm( .await?; let instance: serde_json::Value = instance_resp.json().await?; - let ip = instance["networkInterfaces"][0]["accessConfigs"][0]["natIP"] - .as_str() - .unwrap_or("unknown") - .to_string(); + let ip = extract_private_vm_ip(&instance)?; Ok(ip) } @@ -635,7 +645,7 @@ pub fn agent_routes() -> Router { #[cfg(test)] mod contract_tests { - use super::build_gce_vm_insert_body; + use super::{build_gce_vm_insert_body, extract_private_vm_ip}; #[test] fn contract_create_gce_vm_provision_json_has_no_public_nat() { @@ -680,4 +690,25 @@ mod contract_tests { "provision body must not expose accessConfigs" ); } + + #[test] + fn contract_agent_vm_ip_uses_private_network_ip() { + let instance = serde_json::json!({ + "networkInterfaces": [{ + "networkIP": "10.128.0.42" + }] + }); + + assert_eq!(extract_private_vm_ip(&instance).unwrap(), "10.128.0.42"); + + let public_nat_only = serde_json::json!({ + "networkInterfaces": [{ + "accessConfigs": [{"natIP": "203.0.113.10"}] + }] + }); + assert!( + extract_private_vm_ip(&public_nat_only).is_err(), + "agent VM readiness must not fall back to public natIP/unknown" + ); + } } From 74bd74c3a7faa3fa5045652437acb6b78c2eedb8 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sun, 12 Jul 2026 00:57:50 -0400 Subject: [PATCH 4/5] fix(security): phase-1 agent VM harden without private cutover Keep public NAT and tag omi-agent-vm; gate firewall apply as deferred phase-3. Auth-gate VM /health and update desktop + agent-proxy callers. Verification: cargo test agent::contract python3 -m pytest backend/tests/unit/test_agent_vm_firewall_contract.py -v Related #7326 Co-authored-by: Cursor --- AGENTS.md | 2 +- backend/agent-proxy/main.py | 6 +- .../agent-vm-firewall/firewall-rule.yaml | 30 ++++--- .../scripts/agent-vm-reachability-check.sh | 9 +- backend/scripts/apply-agent-vm-firewall.sh | 24 ++++++ .../unit/test_agent_vm_firewall_contract.py | 77 +++++++++-------- .../macos/Backend-Rust/src/routes/agent.rs | 84 +++++++++---------- .../Desktop/Sources/AgentSyncService.swift | 7 +- .../Desktop/Sources/AgentVMService.swift | 3 +- desktop/macos/agent-cloud/agent.mjs | 10 ++- 10 files changed, 154 insertions(+), 98 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 114216b59bc..3999ee424f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ Helm charts: `backend/charts/{agent-proxy,agent-vm-reaper,backend-listen,backend - **notifications-job** (`modal/job.py`) — Cron job, reads Firestore/Redis, sends push notifications. - **monitoring** (`backend/charts/monitoring/`) — Prometheus, Grafana, Loki, Alloy, alerts, and HPA metric adapters for backend services. - **agent-vm-reaper** (`backend/charts/agent-vm-reaper/`) — CronJob that deletes stale `omi-agent-*` GCE VMs left by desktop agent sandboxes. -- **agent-vm-firewall** (`backend/charts/agent-vm-firewall/`) — GCP firewall IaC allowing private `tcp:8080` before denying public `tcp:8080` to VMs tagged `omi-agent-vm`; apply via `backend/scripts/apply-agent-vm-firewall.sh`, verify via `backend/scripts/agent-vm-reachability-check.sh`. +- **agent-vm-firewall** (`backend/charts/agent-vm-firewall/`) — Deferred phase-3 GCP firewall IaC (private allow + public deny for tag `omi-agent-vm`). **Do not apply** until desktop upload/sync is proxied and proxy↔VM reachability exists; `apply-agent-vm-firewall.sh` refuses without `AGENT_VM_FIREWALL_APPLY_PHASE3`. Hermetic verify: `backend/scripts/agent-vm-reachability-check.sh`. - **backend-secrets** (`backend/charts/backend-secrets/`) — ExternalSecret and SecretStore resources that sync backend runtime secrets into GKE namespaces. Backend runtime env contract: keep `backend/deploy/runtime_env.yaml` aligned with GKE Helm values and Cloud Run runtime env; run `backend/scripts/pre-deploy-check.sh` after backend runtime env or deploy workflow changes. diff --git a/backend/agent-proxy/main.py b/backend/agent-proxy/main.py index 0103c76b45b..8471388dc54 100644 --- a/backend/agent-proxy/main.py +++ b/backend/agent-proxy/main.py @@ -247,10 +247,11 @@ async def _ensure_vm_running(uid: str, vm: Dict[str, Any], health_failed: bool = async def _wait_for_vm_healthy(vm_ip: str, auth_token: str, timeout: float = 120) -> bool: """Poll the VM's /health endpoint until it responds OK.""" deadline = asyncio.get_event_loop().time() + timeout + headers = {"Authorization": f"Bearer {auth_token}"} async with httpx.AsyncClient(timeout=5) as client: while asyncio.get_event_loop().time() < deadline: try: - resp = await client.get(f"http://{vm_ip}:8080/health") + resp = await client.get(f"http://{vm_ip}:8080/health", headers=headers) if resp.status_code == 200: return True except Exception: @@ -421,8 +422,9 @@ async def agent_ws(websocket: WebSocket): # Only fall back to GCE check + restart if the VM isn't reachable. if vm.get("status") == "ready" and vm_ip: try: + headers = {"Authorization": f"Bearer {vm_token}"} if vm_token else {} async with httpx.AsyncClient(timeout=3) as client: - resp = await client.get(f"http://{vm_ip}:8080/health") + resp = await client.get(f"http://{vm_ip}:8080/health", headers=headers) if resp.status_code != 200: raise Exception(f"health returned {resp.status_code}") except Exception: diff --git a/backend/charts/agent-vm-firewall/firewall-rule.yaml b/backend/charts/agent-vm-firewall/firewall-rule.yaml index ffc83e2d681..dc7eb016d1c 100644 --- a/backend/charts/agent-vm-firewall/firewall-rule.yaml +++ b/backend/charts/agent-vm-firewall/firewall-rule.yaml @@ -1,15 +1,25 @@ -# GCP firewall rules for agent VM port 8080 exposure control (#7326). +# Agent VM firewall IaC — PHASE 3 / DEFERRED (#7326). # -# Agent VMs are tagged `omi-agent-vm` at provision time (desktop Backend-Rust -# build_gce_vm_insert_body). Keep the private allow rule higher priority than -# the public deny rule: a 0.0.0.0/0 DENY also matches internal VPC sources. +# DO NOT APPLY in production yet. agent-proxy runs on GKE in `omi-prod-vpc-1` +# while agent VMs live on `default` with no VPC peering. Desktop clients also +# talk to VMs directly on public :8080. A public DENY (or removing NAT) breaks +# both paths until: +# 1) Desktop upload/sync is routed through agent-proxy (PR10c) +# 2) Firewall allowlist uses reserved Cloud NAT IPs for proxy egress +# (or VMs move / peer onto the proxy VPC) +# 3) Optional later: drop ONE_TO_ONE_NAT once private reachability is proven # -# Apply (idempotent): -# backend/scripts/apply-agent-vm-firewall.sh +# Phase 1 (this PR): keep public NAT; tag VMs `omi-agent-vm`; auth-gate VM HTTP. +# These rules are the intended end-state shape only. # -# Verify before desktop-backend deploy: +# Apply is intentionally gated: +# AGENT_VM_FIREWALL_APPLY_PHASE3=I_UNDERSTAND_BREAKS_DESKTOP_AND_PROXY \ +# backend/scripts/apply-agent-vm-firewall.sh +# +# Hermetic verify (safe anytime): # backend/scripts/agent-vm-reachability-check.sh -# backend/scripts/agent-vm-reachability-check.sh --live based-hardware +applyEnabled: false +phase: 3 firewallRules: - name: omi-agent-vm-allow-private-8080 project: based-hardware @@ -27,7 +37,7 @@ firewallRules: - 192.168.0.0/16 targetTags: - omi-agent-vm - description: Allow private VPC ingress from agent-proxy to agent VM HTTP/WS port 8080 (#7326) + description: "PHASE3 deferred: allow private VPC ingress to agent VM :8080 (#7326)" - name: omi-agent-vm-deny-public-8080 project: based-hardware network: default @@ -42,4 +52,4 @@ firewallRules: - 0.0.0.0/0 targetTags: - omi-agent-vm - description: Deny public internet ingress to agent VM HTTP/WS port 8080 (#7326) + description: "PHASE3 deferred: deny public internet ingress to agent VM :8080 (#7326)" diff --git a/backend/scripts/agent-vm-reachability-check.sh b/backend/scripts/agent-vm-reachability-check.sh index f484d5d1437..6670186b126 100755 --- a/backend/scripts/agent-vm-reachability-check.sh +++ b/backend/scripts/agent-vm-reachability-check.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash # Hermetic contract check (default) or live GCP verification for agent VM firewall. +# +# Phase 1: hermetic checks only validate deferred IaC shape. +# Live (--live) expects phase-3 rules already applied — do not use until cutover. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -10,10 +13,12 @@ usage() { Usage: backend/scripts/agent-vm-reachability-check.sh [--live PROJECT] Hermetic (default): - - validate firewall IaC allows private tcp:8080 before denying public tcp:8080 + - validate deferred firewall IaC allows private tcp:8080 before denying public tcp:8080 + - confirm apply script is phase-3 gated -Live (--live, requires gcloud auth): +Live (--live, requires gcloud auth) — phase 3 only: - describe the applied firewall rules in GCP and enforce the same contract + - do not run until AGENT_VM_FIREWALL_APPLY_PHASE3 cutover has completed EOF } diff --git a/backend/scripts/apply-agent-vm-firewall.sh b/backend/scripts/apply-agent-vm-firewall.sh index 1540c810af2..69897c627c9 100755 --- a/backend/scripts/apply-agent-vm-firewall.sh +++ b/backend/scripts/apply-agent-vm-firewall.sh @@ -1,15 +1,39 @@ #!/usr/bin/env bash # Idempotently apply the agent VM port-8080 firewall rules from IaC. +# +# PHASE 3 / DEFERRED: refused unless AGENT_VM_FIREWALL_APPLY_PHASE3 is set. +# See backend/charts/agent-vm-firewall/firewall-rule.yaml for prerequisites. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" RULE_FILE="${ROOT}/charts/agent-vm-firewall/firewall-rule.yaml" +APPLY_GATE_VALUE="I_UNDERSTAND_BREAKS_DESKTOP_AND_PROXY" if [[ ! -f "$RULE_FILE" ]]; then echo "ERROR: missing firewall IaC: $RULE_FILE" >&2 exit 1 fi +if [[ "${AGENT_VM_FIREWALL_APPLY_PHASE3:-}" != "$APPLY_GATE_VALUE" ]]; then + cat >&2 </dev/null 2>&1; then echo "ERROR: gcloud is required to apply agent VM firewall rules" >&2 exit 1 diff --git a/backend/tests/unit/test_agent_vm_firewall_contract.py b/backend/tests/unit/test_agent_vm_firewall_contract.py index 7e40de4c74a..d602eb9ab88 100644 --- a/backend/tests/unit/test_agent_vm_firewall_contract.py +++ b/backend/tests/unit/test_agent_vm_firewall_contract.py @@ -1,20 +1,29 @@ -"""Hermetic contract tests for agent VM network exposure controls (#7326).""" +"""Hermetic contract tests for agent VM network exposure controls (#7326). + +Phase 1 keeps public NAT and gates firewall apply. These tests lock the prepare +surface: firewall-tag on provision, deferred IaC shape, and apply refuse-by-default. +""" from __future__ import annotations -import json +import os +import subprocess from pathlib import Path -import pytest import yaml REPO_ROOT = Path(__file__).resolve().parents[3] FIREWALL_IAC = REPO_ROOT / "backend" / "charts" / "agent-vm-firewall" / "firewall-rule.yaml" +APPLY_SCRIPT = REPO_ROOT / "backend" / "scripts" / "apply-agent-vm-firewall.sh" AGENT_RS = REPO_ROOT / "desktop" / "macos" / "Backend-Rust" / "src" / "routes" / "agent.rs" +def _load_firewall_doc() -> dict: + return yaml.safe_load(FIREWALL_IAC.read_text()) + + def _load_firewall_rules() -> list[dict]: - return yaml.safe_load(FIREWALL_IAC.read_text())["firewallRules"] + return _load_firewall_doc()["firewallRules"] def _rule(name: str) -> dict: @@ -23,10 +32,16 @@ def _rule(name: str) -> dict: return matches[0] -def _denies_tcp_8080(rule: dict) -> bool: +def _has_tcp_8080(rule: dict) -> bool: return any(entry.get("protocol") == "tcp" and "8080" in (entry.get("ports") or []) for entry in rule["rules"]) +def test_firewall_iac_is_deferred_phase3_and_apply_disabled(): + doc = _load_firewall_doc() + assert doc.get("applyEnabled") is False + assert doc.get("phase") == 3 + + def test_firewall_iac_allows_private_8080_before_public_deny_for_omi_agent_vm_tag(): allow = _rule("omi-agent-vm-allow-private-8080") deny = _rule("omi-agent-vm-deny-public-8080") @@ -35,52 +50,46 @@ def test_firewall_iac_allows_private_8080_before_public_deny_for_omi_agent_vm_ta assert allow["direction"] == "INGRESS" assert set(allow["sourceRanges"]) >= {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} assert allow["targetTags"] == ["omi-agent-vm"] - assert _denies_tcp_8080(allow), "private firewall IaC must allow tcp:8080 from private ranges" + assert _has_tcp_8080(allow), "private firewall IaC must allow tcp:8080 from private ranges" assert deny["action"] == "DENY" assert deny["direction"] == "INGRESS" assert "0.0.0.0/0" in deny["sourceRanges"] assert deny["targetTags"] == ["omi-agent-vm"] - assert _denies_tcp_8080(deny), "firewall IaC must deny tcp:8080 from the public internet" + assert _has_tcp_8080(deny), "firewall IaC must deny tcp:8080 from the public internet" assert allow["priority"] < deny["priority"], "private allow must outrank the 0.0.0.0/0 deny" +def test_apply_script_refuses_without_phase3_gate(): + env = os.environ.copy() + env.pop("AGENT_VM_FIREWALL_APPLY_PHASE3", None) + result = subprocess.run( + ["bash", str(APPLY_SCRIPT)], + capture_output=True, + text=True, + env=env, + check=False, + ) + assert result.returncode == 1 + assert "REFUSED" in result.stderr + assert "PHASE3" in result.stderr or "phase-3" in result.stderr.lower() + + def _provision_insert_body_source() -> str: source = AGENT_RS.read_text() body_fn = source.split("fn build_gce_vm_insert_body", 1)[1] return body_fn.split("\n/// Create a GCE VM", 1)[0] -def test_provision_rust_contract_test_guards_public_ip_exposure(): - """Provision payload contract lives in agent.rs — keep IaC and code linked.""" +def test_provision_keeps_public_nat_and_firewall_tag(): + """Phase 1: public NAT stays; VMs stay tagged for a later cutover.""" source = AGENT_RS.read_text() insert_body = _provision_insert_body_source() assert "fn build_gce_vm_insert_body" in source - assert "contract_create_gce_vm_provision_json_has_no_public_nat" in source - assert "contract_agent_vm_ip_uses_private_network_ip" in source + assert "contract_create_gce_vm_provision_json_keeps_public_nat_and_firewall_tag" in source + assert "contract_agent_vm_ip_uses_public_nat_ip" in source assert '"items": ["omi-agent-vm"]' in insert_body - assert "ONE_TO_ONE_NAT" not in insert_body - assert "accessConfigs" not in insert_body - - -@pytest.mark.parametrize( - "needle", - [ - "natIP", - "ONE_TO_ONE_NAT", - "External NAT", - "accessConfigs", - ], -) -def test_provision_fixture_json_has_no_public_ip_fields(needle: str): - """Serialized GCE insert body must not request or embed public IP fields.""" - fixture = { - "name": "omi-agent-contract", - "machineType": "zones/us-central1-a/machineTypes/e2-small", - "networkInterfaces": [{"network": "global/networks/default"}], - "tags": {"items": ["omi-agent-vm"]}, - } - serialized = json.dumps(fixture) - assert needle not in serialized + assert "ONE_TO_ONE_NAT" in insert_body + assert "accessConfigs" in insert_body diff --git a/desktop/macos/Backend-Rust/src/routes/agent.rs b/desktop/macos/Backend-Rust/src/routes/agent.rs index 2937b0097b6..8899ac7e39e 100644 --- a/desktop/macos/Backend-Rust/src/routes/agent.rs +++ b/desktop/macos/Backend-Rust/src/routes/agent.rs @@ -16,12 +16,17 @@ fn local_harness_agent_disabled() -> bool { std::env::var("ENVIRONMENT").as_deref() == Ok("local-dev-harness") } -fn extract_private_vm_ip(instance: &serde_json::Value) -> Result { - instance["networkInterfaces"][0]["networkIP"] +/// Read the public NAT IP from a GCE instance response. +/// +/// Phase 1 keeps ONE_TO_ONE_NAT: agent-proxy (omi-prod-vpc-1) and desktop clients +/// still reach VMs on the `default` VPC via the public IP. Private-only VMs are +/// deferred until desktop upload/sync is proxied and cross-VPC reachability exists. +fn extract_agent_vm_ip(instance: &serde_json::Value) -> Result { + instance["networkInterfaces"][0]["accessConfigs"][0]["natIP"] .as_str() .filter(|ip| !ip.trim().is_empty()) .map(str::to_string) - .ok_or_else(|| "GCE instance response missing private networkIP".to_string()) + .ok_or_else(|| "GCE instance response missing public natIP".to_string()) } /// POST /v2/agent/provision @@ -337,11 +342,11 @@ async fn get_agent_status( { if let Ok(resp) = resp.send().await { if let Ok(instance) = resp.json::().await { - let ip = match extract_private_vm_ip(&instance) { + let ip = match extract_agent_vm_ip(&instance) { Ok(ip) => ip, Err(e) => { tracing::warn!( - "Could not recover private IP for VM {}: {}", + "Could not recover public IP for VM {}: {}", vm_name, e ); @@ -486,8 +491,8 @@ async fn start_stopped_vm( } } - // Get the VM's (possibly new) private VPC IP. Agent VMs intentionally have - // no public NAT, and agent-proxy reaches them over private networking. + // Get the VM's (possibly new) public NAT IP. Phase 1 keeps public NAT so + // agent-proxy and desktop can reach VMs across VPCs. let instance_url = format!( "https://compute.googleapis.com/compute/v1/projects/{}/zones/{}/instances/{}", project, zone, vm_name @@ -500,13 +505,17 @@ async fn start_stopped_vm( .await?; let instance: serde_json::Value = instance_resp.json().await?; - let ip = extract_private_vm_ip(&instance)?; + let ip = extract_agent_vm_ip(&instance)?; Ok(ip) } /// Build the GCE instances.insert request body for a new agent VM. -/// Agent VMs use private networking only — no public NAT / external IP. +/// +/// Phase 1 keeps ONE_TO_ONE_NAT (public IP) and tags VMs `omi-agent-vm` so a +/// later source-restricted / private-network cutover can target them. Do not +/// remove accessConfigs until desktop upload/sync is proxied and proxy↔VM +/// private reachability exists (VPC peering or shared VPC). fn build_gce_vm_insert_body( vm_name: &str, auth_token: &str, @@ -534,7 +543,11 @@ fn build_gce_vm_insert_body( } }], "networkInterfaces": [{ - "network": "global/networks/default" + "network": "global/networks/default", + "accessConfigs": [{ + "type": "ONE_TO_ONE_NAT", + "name": "External NAT" + }] }], "tags": { "items": ["omi-agent-vm"] @@ -552,7 +565,7 @@ fn build_gce_vm_insert_body( } /// Create a GCE VM from the omi-agent image family. -/// Returns the private VPC IP of the created VM. +/// Returns the public NAT IP of the created VM. async fn create_gce_vm( firestore: &crate::services::FirestoreService, vm_name: &str, @@ -614,8 +627,7 @@ async fn create_gce_vm( } } - // Get the VM's private VPC IP. There is no accessConfigs/natIP when the VM - // is provisioned without a public NAT. + // Get the VM's public NAT IP (phase 1 connectivity model). let instance_url = format!( "https://compute.googleapis.com/compute/v1/projects/{}/zones/{}/instances/{}", project, zone, vm_name @@ -628,7 +640,7 @@ async fn create_gce_vm( .await?; let instance: serde_json::Value = instance_resp.json().await?; - let ip = extract_private_vm_ip(&instance)?; + let ip = extract_agent_vm_ip(&instance)?; Ok(ip) } @@ -645,10 +657,10 @@ pub fn agent_routes() -> Router { #[cfg(test)] mod contract_tests { - use super::{build_gce_vm_insert_body, extract_private_vm_ip}; + use super::{build_gce_vm_insert_body, extract_agent_vm_ip}; #[test] - fn contract_create_gce_vm_provision_json_has_no_public_nat() { + fn contract_create_gce_vm_provision_json_keeps_public_nat_and_firewall_tag() { let body = build_gce_vm_insert_body( "omi-agent-contract", "omi-test-token", @@ -659,10 +671,11 @@ mod contract_tests { let network = &body["networkInterfaces"][0]; assert_eq!(network["network"], "global/networks/default"); - assert!( - network.get("accessConfigs").is_none(), - "agent VM provisioning must not request public NAT" + assert_eq!( + network["accessConfigs"][0]["type"], "ONE_TO_ONE_NAT", + "phase 1 must keep public NAT until private cutover prerequisites land" ); + assert_eq!(network["accessConfigs"][0]["name"], "External NAT"); let tags = body["tags"]["items"] .as_array() @@ -671,44 +684,27 @@ mod contract_tests { tags.iter().any(|tag| tag == "omi-agent-vm"), "provision body must tag VMs for omi-agent-vm firewall policy" ); - - let serialized = serde_json::to_string(&body).expect("provision body serializes"); - assert!( - !serialized.contains("ONE_TO_ONE_NAT"), - "provision body must not contain ONE_TO_ONE_NAT" - ); - assert!( - !serialized.contains("External NAT"), - "provision body must not contain External NAT access config" - ); - assert!( - !serialized.contains("natIP"), - "provision body must not expose public IP fields" - ); - assert!( - !serialized.contains("accessConfigs"), - "provision body must not expose accessConfigs" - ); } #[test] - fn contract_agent_vm_ip_uses_private_network_ip() { + fn contract_agent_vm_ip_uses_public_nat_ip() { let instance = serde_json::json!({ "networkInterfaces": [{ - "networkIP": "10.128.0.42" + "networkIP": "10.128.0.42", + "accessConfigs": [{"natIP": "203.0.113.10"}] }] }); - assert_eq!(extract_private_vm_ip(&instance).unwrap(), "10.128.0.42"); + assert_eq!(extract_agent_vm_ip(&instance).unwrap(), "203.0.113.10"); - let public_nat_only = serde_json::json!({ + let private_only = serde_json::json!({ "networkInterfaces": [{ - "accessConfigs": [{"natIP": "203.0.113.10"}] + "networkIP": "10.128.0.42" }] }); assert!( - extract_private_vm_ip(&public_nat_only).is_err(), - "agent VM readiness must not fall back to public natIP/unknown" + extract_agent_vm_ip(&private_only).is_err(), + "phase 1 readiness must require public natIP (not private-only)" ); } } diff --git a/desktop/macos/Desktop/Sources/AgentSyncService.swift b/desktop/macos/Desktop/Sources/AgentSyncService.swift index dfb091a14db..2cf63fef0ef 100644 --- a/desktop/macos/Desktop/Sources/AgentSyncService.swift +++ b/desktop/macos/Desktop/Sources/AgentSyncService.swift @@ -208,9 +208,12 @@ actor AgentSyncService { return } - guard let url = URL(string: "http://\(vmIP):8080/health") else { return } + guard let url = URL(string: "http://\(vmIP):8080/health?token=\(authToken)") else { return } do { - let (data, _) = try await URLSession.shared.data(from: url) + var request = URLRequest(url: url) + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 10 + let (data, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let dbReady = json["databaseReady"] as? Bool, !dbReady else { return } diff --git a/desktop/macos/Desktop/Sources/AgentVMService.swift b/desktop/macos/Desktop/Sources/AgentVMService.swift index ea8ff3c82ec..e54bdf8a22b 100644 --- a/desktop/macos/Desktop/Sources/AgentVMService.swift +++ b/desktop/macos/Desktop/Sources/AgentVMService.swift @@ -134,8 +134,9 @@ actor AgentVMService { /// Check if the VM needs a database upload by hitting its /health endpoint. private func checkVMNeedsDatabase(vmIP: String, authToken: String) async -> Bool { - guard let healthURL = URL(string: "http://\(vmIP):8080/health") else { return true } + guard let healthURL = URL(string: "http://\(vmIP):8080/health?token=\(authToken)") else { return true } var request = URLRequest(url: healthURL) + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 10 do { diff --git a/desktop/macos/agent-cloud/agent.mjs b/desktop/macos/agent-cloud/agent.mjs index 237ef168a76..23fa1a6dceb 100644 --- a/desktop/macos/agent-cloud/agent.mjs +++ b/desktop/macos/agent-cloud/agent.mjs @@ -1026,8 +1026,14 @@ function startServer() { } const httpServer = createServer((req, res) => { - // Health check endpoint (no auth) - if (req.url === "/health" && req.method === "GET") { + // Health check — auth required (same token as upload/sync/ws). + // Unauthenticated /health previously leaked databaseReady to the public internet. + if ((req.url === "/health" || req.url?.startsWith("/health?")) && req.method === "GET") { + if (!verifyAuth(req)) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Unauthorized" })); + return; + } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", From f8ba9078434aee6401e6d9f9642806cbba5bca6a Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sun, 12 Jul 2026 00:58:54 -0400 Subject: [PATCH 5/5] chore(desktop): add changelog for agent VM health auth Co-authored-by: Cursor --- .../changelog/unreleased/20260712-agent-vm-health-auth.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 desktop/macos/changelog/unreleased/20260712-agent-vm-health-auth.json diff --git a/desktop/macos/changelog/unreleased/20260712-agent-vm-health-auth.json b/desktop/macos/changelog/unreleased/20260712-agent-vm-health-auth.json new file mode 100644 index 00000000000..83853bee276 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260712-agent-vm-health-auth.json @@ -0,0 +1,3 @@ +{ + "change": "Agent cloud VMs now require auth on health checks so database status is not publicly readable" +}