diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index 171a2b9d646..edda943ecf2 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -251,7 +251,7 @@ The Brave Search API key is still configured separately during onboarding or thr Use the `weather` preset when the agent needs read-only weather or geocoding lookups. The Balanced and Open tiers include it by default. -The preset covers Open-Meteo, geocoding, and National Weather Service endpoints without enabling messaging or productivity APIs. +The preset covers read-only public weather and geocoding lookups, including current conditions, forecasts, geocoding, and weather alerts, without enabling messaging or productivity APIs. ```bash $$nemoclaw my-assistant policy-add weather --dry-run diff --git a/nemoclaw-blueprint/policies/presets/weather.yaml b/nemoclaw-blueprint/policies/presets/weather.yaml index b23b8514a9a..a226a777ebf 100644 --- a/nemoclaw-blueprint/policies/presets/weather.yaml +++ b/nemoclaw-blueprint/policies/presets/weather.yaml @@ -9,6 +9,23 @@ network_policies: weather: name: weather endpoints: + # Host the bundled OpenClaw weather skill calls with curl on the pinned + # OpenClaw version declared by agents/openclaw/manifest.yaml (2026.5.27). + # Revalidate this host whenever that version changes; replace this prose + # contract with parsed skill metadata if OpenClaw exposes it. + # Paths are / (city, region, airport code, or coordinates) and /:help. + # + # Why path "/**": wttr.in's location namespace IS the path — /, /, + # /, /~, /:help — so there is no narrower stable prefix to pin to. + # Scope stays bounded: GET/HEAD only (every other method is denied by deny-by-default), + # a single read-only public host, and no credentials are sent. + - host: wttr.in + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: HEAD, path: "/**" } - host: api.open-meteo.com port: 443 protocol: rest diff --git a/test/e2e/live/common-egress-agent.test.ts b/test/e2e/live/common-egress-agent.test.ts index 39979dbbed8..d5ca999f827 100644 --- a/test/e2e/live/common-egress-agent.test.ts +++ b/test/e2e/live/common-egress-agent.test.ts @@ -696,7 +696,7 @@ test("common-egress agent classifies pre-contract provider validation skips", () describe.sequential("common-egress agent live targets", () => { openClawTest( - "C1 OpenClaw balanced includes weather and agent fetches Open-Meteo", + "C1 OpenClaw balanced permits a verified wttr.in curl", { timeout: TEST_TIMEOUT_MS }, async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { const hosted = await assertPrerequisites(host, secrets, skip); @@ -708,7 +708,7 @@ describe.sequential("common-egress agent live targets", () => { contract: [ "OpenClaw balanced onboarding applies weather common-egress endpoints", "balanced scope does not include the broader restcountries public-reference endpoint", - "a real OpenClaw agent turn fetches Open-Meteo through web_fetch", + "a real OpenClaw agent turn validates one wttr.in response and leaves its body as proof", ], }); await registerSandboxCleanup(cleanup, artifacts, host, sandbox, OPENCLAW_BALANCED_SANDBOX); @@ -723,6 +723,7 @@ describe.sequential("common-egress agent live targets", () => { await assertPolicyContains(sandbox, OPENCLAW_BALANCED_SANDBOX, "c1-policy", [ "api.open-meteo.com", "geocoding-api.open-meteo.com", + "wttr.in", ]); await assertPolicyAbsent( sandbox, @@ -730,15 +731,56 @@ describe.sequential("common-egress agent live targets", () => { "c1-balanced-scope", "restcountries.com", ); + const weatherProofPath = `/tmp/nemoclaw-weather-proof-${Date.now()}-${process.pid}.txt`; + const clearWeatherProof = await sandbox.execShell( + OPENCLAW_BALANCED_SANDBOX, + trustedSandboxShellScript(`rm -f ${shellQuote(weatherProofPath)}`), + { + artifactName: "c1-weather-clear-proof", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(clearWeatherProof.exitCode, text(clearWeatherProof)).toBe(0); + // The agent must leave the fetched body behind. The host-side assertion + // independently validates it, so merely echoing the reply token cannot pass. + const weatherProofCommand = [ + "set -eu", + `proof=${shellQuote(weatherProofPath)}`, + "if test -s \"$proof\"; then printf 'WEATHER_AGENT_OK\\n'; exit 0; fi", + "tmp=$(mktemp)", + "trap 'rm -f \"$tmp\"' EXIT", + "curl -fsS --max-time 30 --output \"$tmp\" 'https://wttr.in/:help'", + 'test -s "$tmp"', + "grep -Fq 'Usage:' \"$tmp\"", + "grep -Fq 'Special URLs:' \"$tmp\"", + 'mv "$tmp" "$proof"', + "trap - EXIT", + "printf 'WEATHER_AGENT_OK\\n'", + ].join("; "); await runOpenClawAgentAssertion(host, sandbox, artifacts, { apiKey, expected: "WEATHER_AGENT_OK", label: "c1-agent-weather", sandboxName: OPENCLAW_BALANCED_SANDBOX, - prompt: `Use the web_fetch tool to fetch exactly this URL: -https://api.open-meteo.com/v1/forecast?latitude=47.4979&longitude=19.0402¤t=temperature_2m -After web_fetch returns, reply exactly WEATHER_AGENT_OK if the fetched response contains temperature_2m. Do not fetch any other URL.`, + prompt: `Run exactly this shell command to verify the weather host curl path: +${weatherProofCommand} +Do not use web_fetch, web_search, or any other weather provider. +After it returns, reply with only WEATHER_AGENT_OK. Do not fetch any other URL.`, }); + const weatherProof = await sandbox.execShell( + OPENCLAW_BALANCED_SANDBOX, + trustedSandboxShellScript( + `test -s ${shellQuote(weatherProofPath)} && grep -Fq 'Usage:' ${shellQuote(weatherProofPath)} && grep -Fq 'Special URLs:' ${shellQuote(weatherProofPath)} && sha256sum ${shellQuote(weatherProofPath)}`, + ), + { + artifactName: "c1-weather-agent-proof", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(weatherProof.exitCode, text(weatherProof)).toBe(0); + expect(weatherProof.stdout.trim()).toMatch(/^[a-f0-9]{64}\s+/); await artifacts.writeJson("target-result.json", { id: "common-egress-agent", case: "openclaw-balanced-weather", diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index 42f649f47f4..92823f44f81 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -375,6 +375,7 @@ RUN_NETWORK_POLICY_TEST( contracts: [ "deny-by-default egress", "read-only preset allowlist behavior", + "weather preset allows wttr.in GET and HEAD but denies POST and unrelated hosts", "live policy-add and dry-run behavior", "per-binary policy enforcement", "hot reload without sandbox restart", @@ -496,6 +497,29 @@ RUN_NETWORK_POLICY_TEST( /STATUS_403|ERROR_/, ); + const weatherApply = await applyPreset(host, "weather"); + expect(weatherApply.exitCode, text(weatherApply)).toBe(0); + + const weatherUrl = "https://wttr.in/London"; + await expect(curlStatus(sandbox, weatherUrl, "tc-net-weather-get")).resolves.toMatch( + /^[23][0-9][0-9]$/, + ); + await expect(curlStatus(sandbox, weatherUrl, "tc-net-weather-head", "-I")).resolves.toMatch( + /^[23][0-9][0-9]$/, + ); + await expect(curlStatus(sandbox, weatherUrl, "tc-net-weather-post", "-X POST")).resolves.toBe( + "403", + ); + + const unrelatedAfterWeather = await fetchStatus( + sandbox, + "https://example.com/", + "tc-net-weather-unrelated-denied", + ); + expect(unrelatedAfterWeather, "weather preset must not allow unrelated hosts").toMatch( + /STATUS_403|ERROR_/, + ); + const brewApply = await applyPreset(host, "brew"); expect(brewApply.exitCode, text(brewApply)).toBe(0); const policyListAfterBrew = await runNemoclaw(host, [SANDBOX_NAME, "policy-list"], { @@ -785,6 +809,7 @@ nemoclaw-start node /tmp/nemoclaw-web-fetch-e2e.mjs 'http://host.openshell.inter sandboxName: SANDBOX_NAME, assertions: { denyDefault: true, + weatherReadOnlyPreset: true, brewPreset: true, pypiReadOnlyPreset: true, livePolicyAdd: true, diff --git a/test/weather-policy.test.ts b/test/weather-policy.test.ts new file mode 100644 index 00000000000..66e62a443bd --- /dev/null +++ b/test/weather-policy.test.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { loadAgent } from "../src/lib/agent/defs.ts"; + +type WeatherEndpoint = { + host: string; + port: number; + protocol: string; + enforcement: string; + rules: Array<{ allow: { method: string; path: string } }>; +}; + +type WeatherPreset = { + network_policies?: { + weather?: { + endpoints?: WeatherEndpoint[]; + }; + }; +}; + +const REVIEWED_WTTR_WEATHER_SKILL_OPENCLAW_VERSION = "2026.5.27"; + +describe("weather policy preset", () => { + it("allows only current weather hosts and keeps wttr.in read-only (#1417)", () => { + const presetPath = new URL( + "../nemoclaw-blueprint/policies/presets/weather.yaml", + import.meta.url, + ); + const parsed = YAML.parse(fs.readFileSync(presetPath, "utf8")) as WeatherPreset; + const endpoints = parsed.network_policies?.weather?.endpoints ?? []; + + // wttr.is remains intentionally excluded until a pinned runtime actually + // requires it; weather.yaml records the OpenClaw version review boundary. + expect(endpoints.map(({ host }) => host).sort()).toEqual([ + "api.open-meteo.com", + "api.weather.gov", + "geocoding-api.open-meteo.com", + "wttr.in", + ]); + expect(endpoints.find(({ host }) => host === "wttr.in")).toEqual({ + host: "wttr.in", + port: 443, + protocol: "rest", + enforcement: "enforce", + rules: [ + { allow: { method: "GET", path: "/**" } }, + { allow: { method: "HEAD", path: "/**" } }, + ], + }); + }); + + it("forces wttr.in egress re-review when the OpenClaw pin changes (#1417)", () => { + const openClaw = loadAgent("openclaw"); + + expect( + openClaw.expectedVersion, + "Revalidate the bundled OpenClaw weather skill egress and update the weather host/rule contract before changing the reviewed version", + ).toBe(REVIEWED_WTTR_WEATHER_SKILL_OPENCLAW_VERSION); + }); +});