Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
15 changes: 15 additions & 0 deletions nemoclaw-blueprint/policies/presets/weather.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ network_policies:
weather:
name: weather
endpoints:
# Host the bundled OpenClaw weather skill calls with curl on the pinned
# OpenClaw (2026.5.27).
# 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
28 changes: 22 additions & 6 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 bundled skill fetches wttr.in",
{ 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 returns the digest of the bundled weather skill curl response",
],
});
await registerSandboxCleanup(cleanup, artifacts, host, sandbox, OPENCLAW_BALANCED_SANDBOX);
Expand All @@ -723,21 +723,37 @@ 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 weatherDigestCommand =
"body=$(curl -fsS --max-time 30 'https://wttr.in/London?format=3') && test -n \"$body\" && printf '%s' \"$body\" | sha256sum | awk '{print $1}'";
const weatherDigestProbe = await sandbox.execShell(
OPENCLAW_BALANCED_SANDBOX,
trustedSandboxShellScript(weatherDigestCommand),
{
artifactName: "c1-weather-direct-digest",
env: commandEnv(),
timeoutMs: 60_000,
},
);
expect(weatherDigestProbe.exitCode, text(weatherDigestProbe)).toBe(0);
const weatherDigest = weatherDigestProbe.stdout.trim();
expect(weatherDigest).toMatch(/^[a-f0-9]{64}$/);
await runOpenClawAgentAssertion(host, sandbox, artifacts, {
apiKey,
expected: "WEATHER_AGENT_OK",
expected: weatherDigest,
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: `Use the installed weather skill and run exactly this shell command:
${weatherDigestCommand}
Do not use web_fetch, web_search, or any other weather provider.
After it returns, reply with only the 64-character digest printed by the pipeline. Do not fetch any other URL.`,
});
await artifacts.writeJson("target-result.json", {
id: "common-egress-agent",
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
51 changes: 51 additions & 0 deletions test/weather-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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";

type WeatherEndpoint = {
host: string;
port: number;
protocol: string;
enforcement: string;
rules: Array<{ allow: { method: string; path: string } }>;
};

type WeatherPreset = {
network_policies?: {
weather?: {
endpoints?: WeatherEndpoint[];
};
};
};

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 ?? [];

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