Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/network-policy/integration-policy-examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions nemoclaw-blueprint/policies/presets/weather.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<location> (city, region, airport code, or coordinates) and /:help.
#
# Why path "/**": wttr.in's location namespace IS the path — /<city>, /<airport>,
# /<lat,lon>, /~<place>, /: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
Expand Down
52 changes: 47 additions & 5 deletions test/e2e/live/common-egress-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -723,22 +723,64 @@ 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,
OPENCLAW_BALANCED_SANDBOX,
"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&current=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",
Expand Down
25 changes: 25 additions & 0 deletions test/e2e/live/network-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"], {
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions test/weather-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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: "/**" } },
],
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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);
});
});
Loading