diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 34eb9882a45..45cd90b6ab6 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -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. | + + +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. + + +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 --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. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 11539658778..196ec5890e7 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -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. | + +The **Allow All** policy tier (`NEMOCLAW_POLICY_TIER=allow-all`) and `nemoclaw sandbox:shields:down --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. + + ### Binary-Scoped Endpoint Rules Each network policy entry restricts which executables can reach the endpoint using the `binaries` field. diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox-allow-all.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox-allow-all.yaml new file mode 100644 index 00000000000..c0c62232ab2 --- /dev/null +++ b/nemoclaw-blueprint/policies/openclaw-sandbox-allow-all.yaml @@ -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: "/**" } diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 8a090549712..5073c3458f7 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -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: [] diff --git a/scripts/validate-configs.ts b/scripts/validate-configs.ts index cd24310fd43..3da26662ea4 100755 --- a/scripts/validate-configs.ts +++ b/scripts/validate-configs.ts @@ -9,7 +9,7 @@ // npx tsx scripts/validate-configs.ts # validate all known config files // npx tsx scripts/validate-configs.ts --file --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"; @@ -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", ], }, { @@ -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); +} + +/** 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 = new Set(["integrate.api.nvidia.com"]); /** @@ -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}`); @@ -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`). diff --git a/src/commands/sandbox/shields/down.ts b/src/commands/sandbox/shields/down.ts index 97ed6ecfc2b..a8eee5a752c 100644 --- a/src/commands/sandbox/shields/down.ts +++ b/src/commands/sandbox/shields/down.ts @@ -2,11 +2,10 @@ // 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"; @@ -14,12 +13,16 @@ export default class ShieldsDownCommand extends NemoClawCommand { static strict = true; static summary = "Lower sandbox security shields"; static description = "Temporarily lower sandbox shields."; - static usage = [" [--timeout 5m] [--reason ] [--policy permissive]"]; + static usage = [" [--timeout 5m] [--reason ] [--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 { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b0016494038..0a0816a9025 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -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; + console.log( + " Policy tier 'allow-all': booting with catch-all egress (no network filtering).", + ); + } const tokensByEnvKey = Object.fromEntries( messagingTokenDefs.map(({ envKey, token }) => [envKey, token]), ); @@ -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, diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 25b83ca3a73..a70d12c9dc1 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -73,6 +73,13 @@ export type SetupPolicySelectionDeps = { ) => void; selectPolicyTier: () => Promise; 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[], @@ -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, diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index e7218398dee..9b9ab9402c2 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -3,7 +3,7 @@ // // Policy preset management — list, load, merge, and apply presets. -import type { JsonValue, JsonObject } from "../core/json-types"; +import type { JsonObject, JsonValue } from "../core/json-types"; const fs = require("fs"); const path = require("path"); @@ -13,6 +13,8 @@ const YAML = require("yaml"); const { ROOT, run, runCapture } = require("../runner"); const registry = require("../state/registry"); const { loadAgent } = require("../agent/defs"); +const { cleanupTempDir } = require("../onboard/temp-files"); +const { buildRuntimePermissivePolicy } = require("../shields/permissive-runtime"); // Late-binding access via the module exports so tests can spy on // resolveOpenshell without rewiring requires. const openshellResolveModule = require("../adapters/openshell/resolve"); @@ -1228,6 +1230,23 @@ const PERMISSIVE_POLICY_PATH = path.join( "openclaw-sandbox-permissive.yaml", ); +const ALLOW_ALL_POLICY_PATH = path.join( + ROOT, + "nemoclaw-blueprint", + "policies", + "openclaw-sandbox-allow-all.yaml", +); + +/** + * Resolve the on-disk path to the allow-all catch-all policy. Unlike the + * permissive policy there is no agent-specific variant: the catch-all + * (`host: "*"`) is agent-independent, and live filesystem paths are unioned in + * at apply time via {@link buildRuntimePermissivePolicy}. + */ +function resolveAllowAllPolicyPath(_sandboxName?: string): string { + return ALLOW_ALL_POLICY_PATH; +} + /** * Resolve the on-disk path to the permissive policy YAML for the given * sandbox, honoring the agent-specific override registered in @@ -1271,35 +1290,75 @@ function applyPermissivePolicy(sandboxName: string): void { console.log(" Applied permissive policy."); } +/** + * Apply the allow-all catch-all policy to a running sandbox. Unions the live + * sandbox's filesystem paths into the static allow-all baseline (OpenShell + * rejects path removals on a live sandbox; runtime-injected paths such as /proc + * on GPU or /opt/hermes are absent from the static YAML). Mirrors the runtime + * handling used by `shields down`. + */ +function applyAllowAllPolicy(sandboxName: string): void { + const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); + if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { + throw new Error( + `Invalid or truncated sandbox name: '${sandboxName}'. ` + + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, + ); + } + + const basePath = resolveAllowAllPolicyPath(sandboxName); + if (!fs.existsSync(basePath)) { + throw new Error(`Allow-all policy not found: ${basePath}`); + } + + assertOpenshellResolvable(); + const livePolicyYaml = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + const policyFile = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: livePolicyYaml || null, + readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), + }); + const isTemp = policyFile !== basePath; + console.log(" Applying allow-all policy..."); + try { + run(buildPolicySetCommand(policyFile, sandboxName)); + } finally { + if (isTemp) cleanupTempDir(policyFile, "nemoclaw-permissive-runtime"); + } + console.log(" Applied allow-all policy."); +} + export { - PRESETS_DIR, - PERMISSIVE_POLICY_PATH, - listPresets, - loadPreset, + ALLOW_ALL_POLICY_PATH, + applyAllowAllPolicy, + applyPermissivePolicy, + applyPreset, + applyPresetContent, + applyPresets, + assertOpenshellResolvable, + buildPolicyGetCommand, + buildPolicySetCommand, + clampSetupPolicyPresetNames, + extractPresetEntries, + filterSetupPolicyPresets, + getAppliedPresets, + getGatewayPresets, getPresetEndpoints, getPresetValidationWarning, - setupPolicyPresetSupported, - filterSetupPolicyPresets, + listCustomPresets, + listPresets, listSetupPolicyPresets, - clampSetupPolicyPresetNames, - extractPresetEntries, - parseCurrentPolicy, - buildPolicySetCommand, - buildPolicyGetCommand, - assertOpenshellResolvable, + loadPreset, + loadPresetFromFile, mergePresetIntoPolicy, mergePresetNamesIntoPolicy, - removePresetFromPolicy, - applyPreset, - applyPresets, - applyPresetContent, - loadPresetFromFile, + PERMISSIVE_POLICY_PATH, + PRESETS_DIR, + parseCurrentPolicy, removePreset, - applyPermissivePolicy, + removePresetFromPolicy, + resolveAllowAllPolicyPath, resolvePermissivePolicyPath, - getAppliedPresets, - getGatewayPresets, - listCustomPresets, - selectFromList, selectForRemoval, + selectFromList, + setupPolicyPresetSupported, }; diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 7c27a465061..4c8e6fe451a 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -24,6 +24,7 @@ const { buildPolicySetCommand, parseCurrentPolicy, resolvePermissivePolicyPath, + resolveAllowAllPolicyPath, } = require("../policy"); const { parseDuration, @@ -906,15 +907,19 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { // 2. Determine and apply relaxed policy let policyFile: string; let policyFileIsTemp = false; - if (policyName === "permissive") { - const basePath = resolvePermissivePolicyPath(sandboxName); + if (policyName === "permissive" || policyName === "allow-all") { + const basePath = + policyName === "allow-all" + ? resolveAllowAllPolicyPath(sandboxName) + : resolvePermissivePolicyPath(sandboxName); // Union the live sandbox's filesystem_policy.read_only/read_write into - // the static permissive baseline. OpenShell rejects removal of those - // paths on a live sandbox, and runtime-injected entries (/proc on - // GPU, /opt/hermes on Hermes, /home/linuxbrew on post-#3913 OpenClaw, - // etc.) are not present in the static YAML. See #3942, #3957, #3168. - // policyYaml is the pre-parsed body we already captured for the - // snapshot above — reuse it instead of re-fetching. + // the static baseline. OpenShell rejects removal of those paths on a live + // sandbox, and runtime-injected entries (/proc on GPU, /opt/hermes on + // Hermes, /home/linuxbrew on post-#3913 OpenClaw, etc.) are not present in + // the static YAML. See #3942, #3957, #3168. policyYaml is the pre-parsed + // body we already captured for the snapshot above — reuse it instead of + // re-fetching. (allow-all reuses the same FS-union helper; only its + // network_policies differ — a single catch-all host: "*".) policyFile = buildRuntimePermissivePolicy(basePath, { livePolicyYaml: policyYaml, readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), @@ -924,7 +929,7 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { policyFile = path.resolve(policyName); } else { console.error( - ` Unknown policy "${policyName}". Use "permissive" or a path to a YAML file.`, + ` Unknown policy "${policyName}". Use "permissive", "allow-all", or a path to a YAML file.`, ); return failShieldsCommand(`Unknown policy "${policyName}"`, opts.throwOnError); } @@ -1341,16 +1346,16 @@ function isShieldsDown(sandboxName: string, allowInlineRecovery = false): boolea // --------------------------------------------------------------------------- export { - shieldsDown, - shieldsUp, - shieldsStatus, - isShieldsDown, + DEFAULT_TIMEOUT_SECONDS, + deriveShieldsMode, getShieldsPosture, + isShieldsDown, killTimer, - deriveShieldsMode, - parseDuration, lockAgentConfig, - unlockAgentConfig, MAX_TIMEOUT_SECONDS, - DEFAULT_TIMEOUT_SECONDS, + parseDuration, + shieldsDown, + shieldsStatus, + shieldsUp, + unlockAgentConfig, }; diff --git a/test/policy-selection-allow-all.test.ts b/test/policy-selection-allow-all.test.ts new file mode 100644 index 00000000000..8a1b8f20c94 --- /dev/null +++ b/test/policy-selection-allow-all.test.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Covers the allow-all branch in setupPoliciesWithSelection: when the +// "allow-all" tier is chosen, the catch-all policy is applied directly to the +// running sandbox and preset selection/sync is skipped entirely. + +import { describe, expect, it, vi } from "vitest"; + +// hermes-managed-tools pulls in hermes-provider-auth, which does a runtime +// require("./onboard/providers") that only resolves against the compiled dist +// tree. We don't exercise Hermes tool gateways here, so stub it to keep the +// source-level import graph self-contained. +vi.mock("../src/lib/onboard/hermes-managed-tools", () => ({ + HERMES_TOOL_GATEWAY_PRESET_NAMES: new Set(), + mergeRequiredHermesToolGatewayPolicyPresets: (presets: string[]) => presets, +})); + +import { setupPoliciesWithSelection } from "../src/lib/onboard/policy-selection"; + +function baseDeps(tierName: string, overrides: Record = {}) { + return { + policies: { + setupPolicyPresetSupported: () => true, + listSetupPolicyPresets: () => [{ name: "npm" }, { name: "pypi" }], + listCustomPresets: () => [], + getAppliedPresets: () => [], + clampSetupPolicyPresetNames: (names: string[]) => names, + }, + tiers: { + resolveTierPresets: () => [], + getTier: (name: string) => (name === tierName ? { name } : null), + }, + localInferenceProviders: [], + step: () => {}, + note: () => {}, + isNonInteractive: () => false, + waitForSandboxReady: () => true, + syncPresetSelection: vi.fn(), + selectPolicyTier: async () => tierName, + setPolicyTier: vi.fn(), + applyAllowAllPolicy: vi.fn(), + selectTierPresetsAndAccess: async () => [], + parsePolicyPresetEnv: () => [], + env: {}, + ...overrides, + }; +} + +describe("setupPoliciesWithSelection — allow-all tier", () => { + it("applies the allow-all policy and skips preset sync", async () => { + const deps = baseDeps("allow-all"); + const result = await setupPoliciesWithSelection(deps as never, "sbx"); + + expect(result).toEqual([]); + expect(deps.applyAllowAllPolicy).toHaveBeenCalledWith("sbx"); + expect(deps.setPolicyTier).toHaveBeenCalledWith("sbx", "allow-all"); + expect(deps.syncPresetSelection).not.toHaveBeenCalled(); + }); + + it("records the chosen tier via setPolicyTier", async () => { + const deps = baseDeps("allow-all"); + await setupPoliciesWithSelection(deps as never, "sbx"); + expect(deps.setPolicyTier).toHaveBeenCalledWith("sbx", "allow-all"); + }); + + it("does NOT take the allow-all branch for a normal tier", async () => { + const deps = baseDeps("balanced", { + selectTierPresetsAndAccess: async () => [{ name: "npm", access: "read-write" }], + }); + await setupPoliciesWithSelection(deps as never, "sbx"); + expect(deps.applyAllowAllPolicy).not.toHaveBeenCalled(); + expect(deps.syncPresetSelection).toHaveBeenCalled(); + }); +}); diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts index 6ba72cbea31..4eff5653378 100644 --- a/test/policy-tiers.test.ts +++ b/test/policy-tiers.test.ts @@ -67,13 +67,13 @@ function mustGetTier(name: string): Tier { describe("tiers", () => { describe("listTiers", () => { - it("returns exactly 3 tiers", () => { - expect(tiers.listTiers()).toHaveLength(3); + it("returns exactly 4 tiers", () => { + expect(tiers.listTiers()).toHaveLength(4); }); - it("tiers are ordered restricted → balanced → open", () => { + it("tiers are ordered restricted → balanced → open → allow-all", () => { const names = tiers.listTiers().map((tier: Tier) => tier.name); - expect(names).toEqual(["restricted", "balanced", "open"]); + expect(names).toEqual(["restricted", "balanced", "open", "allow-all"]); }); it("each tier has name, label, description, and presets array", () => { @@ -85,9 +85,9 @@ describe("tiers", () => { } }); - it("labels are human-readable capitalised strings", () => { + it("first three labels are human-readable capitalised strings", () => { const labels = tiers.listTiers().map((tier: Tier) => tier.label); - expect(labels).toEqual(["Restricted", "Balanced", "Open"]); + expect(labels.slice(0, 3)).toEqual(["Restricted", "Balanced", "Open"]); }); }); @@ -107,11 +107,30 @@ describe("tiers", () => { expect(tier.name).toBe("open"); }); + it("returns the allow-all tier", () => { + const tier = mustGetTier("allow-all"); + expect(tier.name).toBe("allow-all"); + }); + it("returns null for an unknown tier", () => { expect(tiers.getTier("nonexistent")).toBeNull(); }); }); + describe("tier: allow-all", () => { + it("has no presets — it swaps the base policy instead of layering presets", () => { + expect(mustGetTier("allow-all").presets).toHaveLength(0); + }); + + it("label and description warn it is dev/testing only", () => { + const tier = mustGetTier("allow-all"); + expect(tier.label.toLowerCase()).toContain("allow all"); + expect(`${tier.label} ${tier.description}`.toLowerCase()).toMatch( + /dev|testing|no egress filtering|catch-all/, + ); + }); + }); + describe("tier: restricted", () => { it("has no presets — base sandbox policy only", () => { expect(mustGetTier("restricted").presets).toHaveLength(0); diff --git a/test/validate-configs-dangerous-hosts.test.ts b/test/validate-configs-dangerous-hosts.test.ts index 75f54fc03c2..330b84b986d 100644 --- a/test/validate-configs-dangerous-hosts.test.ts +++ b/test/validate-configs-dangerous-hosts.test.ts @@ -16,6 +16,8 @@ import { ROUTER_API_BASE_HOST_ALLOWLIST, findDangerousHosts, findDangerousRouterApiBases, + isAllowAllPolicyFile, + isBareWildcardHost, isDangerousHost, } from "../scripts/validate-configs"; @@ -61,6 +63,49 @@ describe("isDangerousHost", () => { }); }); +describe("isAllowAllPolicyFile (allow-all exemption scope)", () => { + it.each([ + "nemoclaw-blueprint/policies/openclaw-sandbox-allow-all.yaml", + "openclaw-sandbox-allow-all.yaml", + "agents/hermes/policy-allow-all.yml", + "C:\\repo\\policies\\some-allow-all.yaml", + ])("treats %s as an allow-all file", (file) => { + expect(isAllowAllPolicyFile(file)).toBe(true); + }); + + it.each([ + "nemoclaw-blueprint/policies/openclaw-sandbox.yaml", + "nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml", + "nemoclaw-blueprint/policies/presets/slack.yaml", + "allow-all.json", + "allow-all-extra.yaml", + ])("does not treat %s as an allow-all file", (file) => { + expect(isAllowAllPolicyFile(file)).toBe(false); + }); +}); + +describe("isBareWildcardHost (exempted family — bare wildcard only)", () => { + it.each(["*", "*:443", " * ", "*:80"])("flags %s as a bare wildcard", (host) => { + expect(isBareWildcardHost(host)).toBe(true); + }); + + it.each(["0.0.0.0", "0.0.0.0/0", "::", "::/0", "*.example.com", "example.com"])( + "does not treat %s as a bare wildcard", + (host) => { + expect(isBareWildcardHost(host)).toBe(false); + }, + ); + + it("never exempts IP catch-alls even though they are dangerous", () => { + // These remain blocked everywhere — including in allow-all files — so a typo + // can never silently widen egress to a raw address range. + for (const host of ["0.0.0.0/0", "::/0"]) { + expect(isDangerousHost(host)).toBe(true); + expect(isBareWildcardHost(host)).toBe(false); + } + }); +}); + describe("findDangerousRouterApiBases", () => { it("allows the public NVIDIA Build endpoint", () => { expect(