Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs/reference/network-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ The baseline policy is always applied regardless of the selected tier.
| Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. |
| Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported` | Full dev tooling and web search for agents that support web search. No messaging platform access. |
| Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging and productivity. |
| Allow All | None — swaps the base policy | Catch-all egress to any public host (no network filtering). Opt-in for trusted dev/testing only. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the surrounding tier description so allow-all is the only documented behavior.

This new table row and warning say allow-all swaps the base policy, but the paragraph above still says the baseline policy is always applied regardless of tier. Please align those statements so the page does not describe two different runtime behaviors.

Also applies to: 72-76

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/reference/network-policies.mdx` at line 70, The doc currently
contradicts itself: the new table row "Allow All" / `allow-all` says it swaps
the base policy but the paragraph above still claims the baseline policy is
always applied; update the surrounding tier description and any nearby
paragraphs (the text around the tiers table and the baseline-policy description)
to reflect that selecting the `allow-all` tier replaces/swaps the base policy
rather than applying it in addition, so the page consistently documents
`allow-all` as the only documented behavior that disables the baseline
filtering.


<Warning>
The **Allow All** tier disables egress filtering. Instead of layering presets onto the deny-by-default baseline, it swaps the base policy for `nemoclaw-blueprint/policies/openclaw-sandbox-allow-all.yaml`, a single catch-all (`host: "*"`) that lets the agent reach **any** public host on ports 80/443. Every reachable host is a potential data-exfiltration path for workspace files, credentials, or conversation history. SSRF and private-network blocking still apply — `localhost`, link-local, and RFC1918 ranges remain blocked — but no HTTP-level filtering is performed. Use it only for trusted development or testing, and prefer a specific tier plus presets, or [operator approval](#operator-approval-flow), for everything else.
</Warning>

Allow All has no presets, so the combined preset screen is skipped. Enable it non-interactively with `NEMOCLAW_POLICY_TIER=allow-all`, or apply it to an already-running sandbox with `nemoclaw sandbox:shields:down <name> --policy allow-all`.

After selecting a tier, a combined preset and access-mode screen lets you include or exclude individual presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access.
Tier-default presets are pre-selected; additional presets can be added from the full list.
Expand Down
4 changes: 4 additions & 0 deletions docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ The sandbox blocks all outbound connections unless you explicitly list the endpo
| Risk if relaxed | Each allowed endpoint is a potential data exfiltration path. The agent can send workspace content, credentials, or conversation history to any reachable host. |
| Recommendation | Add only endpoints the agent needs for its task. Prefer operator approval for one-off requests over permanently widening the baseline. |

<Warning>
The **Allow All** policy tier (`NEMOCLAW_POLICY_TIER=allow-all`) and `nemoclaw sandbox:shields:down <name> --policy allow-all` disable egress filtering entirely with a catch-all `host: "*"` rule. This removes the deny-by-default protection above for the selected sandbox: the agent can reach **any** public host. SSRF and private-network blocking still apply (`localhost`, link-local, and RFC1918 ranges stay blocked), but no host, method, or path filtering is performed. Treat allow-all as trusted-development-only. It is never the shipped default — deny-by-default remains in effect unless you opt in.
</Warning>

### Binary-Scoped Endpoint Rules

Each network policy entry restricts which executables can reach the endpoint using the `binaries` field.
Expand Down
67 changes: 67 additions & 0 deletions nemoclaw-blueprint/policies/openclaw-sandbox-allow-all.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Allow-all network posture — opt-in via the "allow-all" policy tier
# (NEMOCLAW_POLICY_TIER=allow-all) or `shields down --policy allow-all`.
#
# A single catch-all network policy (host: "*") permits egress to ANY public
# host on ports 80/443 with full L7 access. This is the only NemoClaw policy
# that uses a bare "*" host; the dangerous-host guardrail in
# scripts/validate-configs.ts exempts files named *-allow-all.yaml for exactly
# this reason (subdomain wildcards remain the norm everywhere else).
#
# WARNING: This disables egress filtering. Every reachable host becomes a
# potential data-exfiltration path (workspace files, credentials, conversation
# history). SSRF / private-network blocking (ssrf.ts, private-networks.ts) still
# applies — localhost, link-local, and RFC1918 ranges remain blocked. Do not use
# in production. Prefer a specific tier + presets, or operator approval, instead.
#
# NOTE: bare host "*" support in the OpenShell L7 proxy must be verified on a
# live sandbox before relying on this file. If the proxy rejects "*", switch the
# endpoints below to `enforcement: audit` (log-only, non-blocking). See the
# allow-all rollout task / docs/reference/network-policies.mdx.

version: 1

filesystem_policy:
# Mirrors openclaw-sandbox-permissive.yaml. OpenShell rejects include_workdir
# changes and filesystem path removals on live sandboxes.
include_workdir: true
read_only:
- /usr
- /lib
- /proc
- /dev/urandom
- /app
- /etc
- /var/log
read_write:
- /tmp
- /dev/null
- /sandbox/.openclaw
- /sandbox/.nemoclaw
- /home/linuxbrew

landlock:
compatibility: best_effort

process:
run_as_user: sandbox
run_as_group: sandbox

network_policies:
allow_all:
name: allow_all
endpoints:
- host: "*"
port: 443
protocol: rest
enforcement: enforce
access: full
- host: "*"
port: 80
protocol: rest
enforcement: enforce
access: full
binaries:
- { path: "/**" }
11 changes: 11 additions & 0 deletions nemoclaw-blueprint/policies/tiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,14 @@ tiers:
- { name: whatsapp, access: read-write }
- { name: jira, access: read-write }
- { name: outlook, access: read-write }

# DANGER: disables egress filtering. Unlike the other tiers, allow-all does
# NOT layer presets onto the deny-by-default base — it swaps the base policy
# for openclaw-sandbox-allow-all.yaml (a catch-all host: "*"), so the agent can
# reach ANY public host. SSRF / private-network blocking still applies. Has no
# presets by design; the catch-all makes per-service presets moot. Opt in only
# for trusted dev/testing. See docs/reference/network-policies.mdx.
- name: allow-all
label: Allow All (no egress filtering — dev/testing only)
description: Catch-all egress to any public host. Swaps the base policy for the allow-all catch-all; presets do not apply. Maximum scope — you accept full responsibility.
presets: []
Comment on lines +53 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Call out the 80/443 limit in the tier copy.

The selector text reads like unrestricted outbound access, but openclaw-sandbox-allow-all.yaml still only permits host: "*" on ports 80 and 443. As written, the UI copy promises broader reach than the policy actually grants.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemoclaw-blueprint/policies/tiers.yaml` around lines 53 - 56, The tier
"allow-all" description promises unrestricted outbound access but the actual
policy in openclaw-sandbox-allow-all.yaml only permits host: "*" on ports 80 and
443; update the "allow-all" tier copy to explicitly call out the 80/443
restriction (e.g., append that outbound is limited to ports 80 and 443) or
alternatively remove/adjust the port restriction in
openclaw-sandbox-allow-all.yaml so the description matches the policy; be sure
to edit the "name: allow-all" tier description string to reflect the chosen fix.

39 changes: 34 additions & 5 deletions scripts/validate-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// npx tsx scripts/validate-configs.ts # validate all known config files
// npx tsx scripts/validate-configs.ts --file <config> --schema <schema> # validate one file

import { existsSync, readFileSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import Ajv from "ajv/dist/2020.js";
Expand Down Expand Up @@ -46,6 +46,7 @@ function discoverTargets(): ConfigTarget[] {
files: [
"nemoclaw-blueprint/policies/openclaw-sandbox.yaml",
"nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml",
"nemoclaw-blueprint/policies/openclaw-sandbox-allow-all.yaml",
],
},
{
Expand Down Expand Up @@ -209,6 +210,24 @@ interface DangerousHostFinding {
host: string;
}

/**
* Designated allow-all policy files (basename `*-allow-all.yaml`) are the one
* place a bare catch-all `host: "*"` is intentional — they implement the opt-in
* allow-all posture. Such files are exempted ONLY for the bare wildcard family
* (`*` / `*:port`); IP catch-alls (`0.0.0.0/0`, `::/0`, …) stay rejected
* everywhere so a typo can never silently widen egress to raw address ranges.
*/
function isAllowAllPolicyFile(file: string): boolean {
const base = file.replaceAll("\\", "/").split("/").pop() ?? "";
return /-allow-all\.ya?ml$/.test(base);
}
Comment on lines +220 to +223

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't key the wildcard exemption off basename alone.

discoverTargets() also validates every preset under nemoclaw-blueprint/policies/presets/. With the current helper, a preset like slack-allow-all.yaml would bypass the dangerous-host guard purely because of its filename, even though this PR's contract is that only the dedicated allow-all base policy may use bare *.

Suggested direction
+const ALLOW_ALL_POLICY_FILES = new Set([
+  "nemoclaw-blueprint/policies/openclaw-sandbox-allow-all.yaml",
+]);
+
 function isAllowAllPolicyFile(file: string): boolean {
-  const base = file.replaceAll("\\", "/").split("/").pop() ?? "";
-  return /-allow-all\.ya?ml$/.test(base);
+  return ALLOW_ALL_POLICY_FILES.has(file.replaceAll("\\", "/"));
 }

If you want this to stay extensible, the important part is scoping the exemption by path/schema kind, not by basename.

Also applies to: 363-367

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/validate-configs.ts` around lines 220 - 223, The helper
isAllowAllPolicyFile currently keys the wildcard exemption on the basename only,
letting files like presets/slack-allow-all.yaml bypass checks; update it to
scope the exemption by path/schema kind instead of basename by verifying the
file's full relative path or directory (e.g., ensure the file resides in the
dedicated base policy directory or matches the exact allowed base policy path
used by discoverTargets) before returning true; adjust the same logic used at
the other occurrence (lines mentioned around 363-367) so only the dedicated
allow-all base policy can use a bare "*" and presets or other policy locations
are not exempted.


/** True for the bare wildcard catch-all family ("*" / "*:443"), excluding IP catch-alls. */
function isBareWildcardHost(host: string): boolean {
const trimmed = host.trim();
return trimmed === "*" || trimmed.startsWith("*:");
}

const ROUTER_API_BASE_HOST_ALLOWLIST: ReadonlySet<string> = new Set(["integrate.api.nvidia.com"]);

/**
Expand Down Expand Up @@ -339,7 +358,15 @@ function main(): void {
const schemaErrors = !valid && validate.errors ? validate.errors.length : 0;
// Semantic check: walk the parsed doc and reject catch-all hosts.
// Runs regardless of schema outcome so operators see all issues at once.
const dangerous = [...findDangerousHosts(data), ...findDangerousRouterApiBases(data)];
// Designated allow-all files may use the bare wildcard ("*"/"*:port") on
// purpose; everything else (and all IP catch-alls) stays rejected.
const allowBareWildcard = isAllowAllPolicyFile(file);
const dangerous = [
...findDangerousHosts(data).filter(
(finding) => !(allowBareWildcard && isBareWildcardHost(finding.host)),
),
...findDangerousRouterApiBases(data),
];

if (schemaErrors > 0 || dangerous.length > 0) {
console.error(`FAIL: ${file}`);
Expand Down Expand Up @@ -373,11 +400,13 @@ function main(): void {
// Export for unit tests without re-running main().
export {
DANGEROUS_HOSTS,
ROUTER_API_BASE_HOST_ALLOWLIST,
isDangerousHost,
discoverTargets,
findDangerousHosts,
findDangerousRouterApiBases,
discoverTargets,
isAllowAllPolicyFile,
isBareWildcardHost,
isDangerousHost,
ROUTER_API_BASE_HOST_ALLOWLIST,
};

// Only run main() when invoked directly (skip on test `import`).
Expand Down
13 changes: 8 additions & 5 deletions src/commands/sandbox/shields/down.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,27 @@
// SPDX-License-Identifier: Apache-2.0

import { Flags } from "@oclif/core";
import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";

import { shieldsTimeoutDurationFlag } from "../../../lib/cli/duration-flags";
import * as shields from "../../../lib/shields/index";
import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";
import { sandboxNameArg } from "../../../lib/sandbox/command-support";
import * as shields from "../../../lib/shields/index";

export default class ShieldsDownCommand extends NemoClawCommand {
static id = "sandbox:shields:down";
static hidden = true;
static strict = true;
static summary = "Lower sandbox security shields";
static description = "Temporarily lower sandbox shields.";
static usage = ["<name> [--timeout 5m] [--reason <text>] [--policy permissive]"];
static usage = ["<name> [--timeout 5m] [--reason <text>] [--policy permissive|allow-all]"];
static args = { sandboxName: sandboxNameArg };
static flags = {
timeout: shieldsTimeoutDurationFlag({ description: "Duration before shields are restored" }),
reason: Flags.string({ description: "Reason for lowering shields" }),
policy: Flags.string({ description: "Policy to apply while shields are down" }),
policy: Flags.string({
description:
'Policy while shields are down: "permissive" (full access on known hosts), ' +
'"allow-all" (catch-all egress to any host — dev/testing only), or a path to a YAML file',
}),
};

public async run(): Promise<void> {
Expand Down
19 changes: 18 additions & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3364,7 +3364,23 @@ async function createSandbox(
"policies",
"openclaw-sandbox.yaml",
);
const basePolicyPath = (agent && agentOnboard.getAgentPolicyPath(agent)) || defaultPolicyPath;
let basePolicyPath = (agent && agentOnboard.getAgentPolicyPath(agent)) || defaultPolicyPath;
// Allow-all is not a preset bundle — it swaps the base policy for the
// catch-all (host: "*"). At create time the tier is known only when it comes
// from the environment (non-interactive) or a recorded recreate; interactive
// fresh onboards choose the tier post-create and apply it via
// setupPoliciesWithSelection. Swapping the base here means an env/recreate
// allow-all sandbox boots permissive from the very first request.
const recordedTier = registry.getSandbox(sandboxName)?.policyTier ?? null;
const envTier = (process.env.NEMOCLAW_POLICY_TIER || "").trim().toLowerCase();
const allowAllAtCreate =
envTier === "allow-all" || (recordedTier === "allow-all" && !envTier);
if (allowAllAtCreate) {
basePolicyPath = policies.ALLOW_ALL_POLICY_PATH;
Comment on lines +3374 to +3379

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Read the previous policy tier before the recreate flow clears the registry entry.

On the recreate path, Line 3264 already removes the sandbox from the registry before this lookup runs, so registry.getSandbox(sandboxName)?.policyTier is null here. A sandbox that previously used allow-all therefore reboots with the default base policy unless NEMOCLAW_POLICY_TIER is set again, which breaks the intended first-boot behavior for recreated allow-all sandboxes. Capture the prior tier before prune/delete and thread that value into this decision instead.

Based on PR objectives, recreated allow-all sandboxes are supposed to boot with the recorded tier’s base policy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard.ts` around lines 3374 - 3379, The code reads recordedTier via
registry.getSandbox(sandboxName)?.policyTier after the recreate/prune path has
already removed the sandbox, so recreated sandboxes lose their previous
"allow-all" tier; change the flow to capture the sandbox's prior policyTier
before the prune/delete runs (e.g. fetch and store the value earlier where the
sandbox still exists), then use that stored value instead of calling
registry.getSandbox(...) when computing recordedTier and allowAllAtCreate (which
influences basePolicyPath and policies.ALLOW_ALL_POLICY_PATH); ensure the stored
priorTier is threaded into the logic that sets allowAllAtCreate/basePolicyPath
so recreated sandboxes retain the recorded tier defaults.

console.log(
" Policy tier 'allow-all': booting with catch-all egress (no network filtering).",
);
}
Comment on lines +3367 to +3383

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Move the new allow-all onboarding glue behind a helper to get the guardrail green.

CI is already failing because src/lib/onboard.ts exceeded its growth budget, and these inline allow-all additions are the new surface. Hoisting the base-policy resolution and extra dependency wiring into an existing policy helper/module should preserve behavior while bringing this file back under the guardrail.

As per pipeline failures, src/lib/onboard.ts is currently blocked by the codebase growth guardrail.

Also applies to: 6342-6358

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard.ts` around lines 3367 - 3383, Extract the allow-all base
policy resolution logic from onboard.ts into a helper in the existing policy
helper/module (e.g., add a function like resolveBasePolicyPath(agent,
sandboxName) in the policies helper) so onboard.ts no longer contains the inline
env/recordedTier/allowAllAtCreate logic; the new helper should: compute
basePolicyPath = (agent && agentOnboard.getAgentPolicyPath(agent)) ||
defaultPolicyPath, read recordedTier via
registry.getSandbox(sandboxName)?.policyTier, read envTier from
process.env.NEMOCLAW_POLICY_TIER, determine allowAllAtCreate and, if true,
return policies.ALLOW_ALL_POLICY_PATH and the decision message (or a boolean)
otherwise return the normal basePolicyPath; then update onboard.ts to call this
new helper (replacing basePolicyPath, recordedTier, envTier, and
allowAllAtCreate usage) and remove the inline block so behavior (including the
console message about allow-all) remains identical while reducing onboard.ts
size.

Source: Pipeline failures

const tokensByEnvKey = Object.fromEntries(
messagingTokenDefs.map(({ envKey, token }) => [envKey, token]),
);
Expand Down Expand Up @@ -6335,6 +6351,7 @@ async function setupPoliciesWithSelection(
syncPresetSelection,
selectPolicyTier,
setPolicyTier: (sandbox, tierName) => registry.updateSandbox(sandbox, { policyTier: tierName }),
applyAllowAllPolicy: (sandbox) => policies.applyAllowAllPolicy(sandbox),
selectTierPresetsAndAccess,
parsePolicyPresetEnv,
env: process.env,
Expand Down
23 changes: 23 additions & 0 deletions src/lib/onboard/policy-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ export type SetupPolicySelectionDeps = {
) => void;
selectPolicyTier: () => Promise<string>;
setPolicyTier?: (sandboxName: string, tierName: string) => void;
/**
* Apply the allow-all catch-all policy to a running sandbox. Used when the
* "allow-all" tier is chosen interactively (a fresh onboard creates with the
* default base, so the catch-all must be applied post-create). Env/recreate
* allow-all sandboxes already boot with the catch-all base.
*/
applyAllowAllPolicy?: (sandboxName: string) => void;
selectTierPresetsAndAccess: (
tierName: string,
presets: Preset[],
Expand Down Expand Up @@ -330,6 +337,22 @@ async function setupPoliciesWithSelectionInner(

const tierName = await deps.selectPolicyTier();
deps.setPolicyTier?.(sandboxName, tierName);

// Allow-all is a catch-all posture, not a preset bundle. Apply the catch-all
// policy directly and skip preset selection entirely. (Env/recreate
// allow-all already booted with the catch-all base; this covers the
// interactive fresh-onboard case, and is idempotent for the others.)
if (tierName === "allow-all") {
if (onSelection) onSelection([]);
if (!deps.waitForSandboxReady(sandboxName)) {
console.error(` Sandbox '${sandboxName}' was not ready for policy application.`);
process.exit(1);
}
deps.note(" Applying allow-all policy: catch-all egress to any host (no network filtering).");
deps.applyAllowAllPolicy?.(sandboxName);
return [];
}

const suggestions = pruneDisabledPresets(
computeSetupPresetSuggestions(deps, tierName, {
enabledChannels,
Expand Down
Loading
Loading