diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx
index ead272515c6..c260121d6e9 100644
--- a/docs/reference/commands.mdx
+++ b/docs/reference/commands.mdx
@@ -528,13 +528,14 @@ It then prompts for optional web search and messaging channels, builds and start
It then prompts for optional web search, builds and starts the sandbox, and asks for a **policy tier** that controls the default set of network policy presets applied to the sandbox.
-Three tiers are available:
+Four tiers are available:
| Tier | Description |
|------|-------------|
| Restricted | No tier defaults. Web search or other integrations selected earlier can still add their required presets; deselect them during policy review for baseline-only access. |
| Balanced (default) | Full dev tooling and a selected, supported web search provider. Package installs, model downloads, and inference. No messaging platform access by default. |
| Open | Broad access across third-party services including supported messaging and productivity presets. Agent-specific unsupported presets are filtered out. |
+| Personal | Lets every sandbox binary open TCP connections to public and private address ranges on destination ports 80 and 443. Unspecified, loopback, and link-local ranges remain blocked. Also selects every maintained preset supported by the active agent. Intended only for trusted personal-use workloads. |
After selecting a tier, the wizard shows a combined preset and access-mode screen where you can include or exclude individual presets and toggle each between read and read-write access.
For details on tiers and the presets each includes, refer to [Network Policies](network-policies#policy-tiers).
@@ -549,7 +550,7 @@ NEMOCLAW_POLICY_TIER=restricted $$nemoclaw onboard --non-interactive --yes-i-acc
```
Unset, blank, or whitespace-only `NEMOCLAW_POLICY_TIER` values use the `balanced` default.
-In non-interactive mode, any non-blank value must be one of `restricted`, `balanced`, or `open`; otherwise onboarding exits before preflight, gateway, or inference side effects with an error listing the valid options.
+In non-interactive mode, any non-blank value must be one of `restricted`, `balanced`, `open`, or `personal`; otherwise onboarding exits before preflight, gateway, or inference side effects with an error listing the valid options.
Interactive onboarding ignores an invalid environment value and shows the normal tier prompt.
`NEMOCLAW_POLICY_MODE` controls how non-interactive onboarding reconciles the tier-derived suggestions against the sandbox's currently-applied presets.
@@ -560,6 +561,7 @@ Onboarding removes any preset that is not in the list.
`skip` leaves the applied set untouched and does not apply tier defaults.
NemoClaw filters tier suggestions and resume selections by active agent support and the selected web search provider.
During automatic suggestion and resume reconciliation, it removes stale web-search selections when they conflict with the active agent or selected provider.
+The Personal tier is the exception: it preserves every applicable maintained web-search preset even when onboarding did not configure that provider.
For Hermes, this includes replacing stale `nous-web` when Tavily is selected.
diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx
index dc20c7faabc..16a607d2217 100644
--- a/docs/reference/network-policies.mdx
+++ b/docs/reference/network-policies.mdx
@@ -136,10 +136,26 @@ This is the supported registry-backed way to replay a live exact-key removal dur
| Restricted | No tier defaults | Starts from the baseline policy. Web search or messaging integrations selected earlier can still suggest their required presets; deselect them during policy review for baseline-only access. Restricted suppresses other agent-required additions; reapply them later with `policy add` only after reviewing the additional egress. |
| Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, selected `brave` or `tavily` web search preset | Full dev tooling and web search when you select a provider the active agent supports. No messaging platform access. Apply the `weather` preset explicitly if your agent needs read-only weather lookups. |
| Open | `npm`, `pypi`, `huggingface`, `brew`, selected `brave` or `tavily` web search preset, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. |
+| Personal | `personal-open-internet` and every maintained preset supported by the active agent | Lets every sandbox binary open TCP connections to public and private address ranges on destination ports `80` and `443`. Unspecified, loopback, and link-local ranges remain blocked. Also selects every maintained preset applicable to the active agent. |
+
+
+The Personal tier applies the `personal-open-internet` policy preset with a hostless L4 endpoint on destination ports `80` and `443`.
+The policy matches any requested host on either port, then permits the connection only when every resolved address is in the preset's allowed ranges.
+The rule does not inspect the application protocol or payload, so traffic on these ports is not limited to HTTP or HTTPS.
+OpenShell does not restrict the hostname, HTTP method, path, or body after the rule permits the connection.
+An agent can send workspace data or sandbox-visible credentials to an arbitrary reachable service on either port without an operator approval prompt.
+
+The preset excludes unspecified, loopback, and link-local address ranges, including the common cloud metadata range.
+OpenShell also keeps its hard blocks for those destinations.
+Other destination ports remain denied unless another policy entry permits them.
+The sandbox's filesystem, process, gateway authentication, and managed credential controls remain active.
+Use this tier only for trusted personal workloads with trusted prompts and data.
+
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 built-in preset list available to the sandbox's active agent.
NemoClaw filters tier defaults and built-in preset choices by the active agent's supported integrations.
+The `personal-open-internet` preset uses L4 passthrough, so its read-write label does not add HTTP method or path inspection.
OpenClaw can select `brave` or `tavily`, while Hermes can select `tavily` only.
@@ -150,6 +166,7 @@ Hermes can select `tavily` only.
Deep Agents can use the maintained `tavily` opt-in path, but messaging channel presets are omitted because the terminal harness does not run a NemoClaw messaging bridge today.
NemoClaw automatically suggests the preset that matches the selected provider and removes stale web search presets during resume reconciliation when you switch providers or disable web search.
+The Personal tier instead keeps every applicable maintained web-search preset selected by default.
Explicit custom preset lists and manual interactive selections remain operator-controlled.
Hermes managed-tool gateway selections can add Hermes-specific presets, such as Nous-hosted web, image, audio, browser, or code tools, without applying unsupported OpenClaw-only presets.
@@ -157,18 +174,19 @@ When Hermes uses Tavily, NemoClaw removes `nous-web` from the effective managed-
OpenClaw onboarding also adds the `openclaw-pricing` preset on top of tier defaults so session-cost records can populate from LiteLLM and OpenRouter without manual configuration.
-When the OpenClaw OTEL diagnostics feature is enabled with a local endpoint, NemoClaw adds the `openclaw-diagnostics-otel-local` preset on the same basis.
+On the Balanced and Open tiers, enabling OpenClaw OTEL diagnostics with a local endpoint adds the `openclaw-diagnostics-otel-local` preset.
+The Personal tier selects that preset by default, while Restricted suppresses it during reconciliation.
-When LangChain Deep Agents Code is onboarded with `--observability`, NemoClaw adds the `observability-otlp-local` preset on Balanced and Open tiers.
+When LangChain Deep Agents Code is onboarded with `--observability`, NemoClaw adds the `observability-otlp-local` preset on Balanced, Open, and Personal tiers.
The Restricted tier suppresses this agent-required preset during onboarding and rebuild reconciliation.
An operator can add it manually after reviewing the additional egress, but the next Restricted reconciliation removes it.
The applied set therefore reflects the chosen tier *plus* any agent-required presets, so `policy list` may show one or more presets that do not appear in the tier table above.
The `policy list` provenance tags are inferred from the current tier YAML and the active agent at display time and are not persisted per preset.
A preset whose name matches an entry in the sandbox's current tier definition is labelled `[from tier]` even when an operator added it manually with `policy add` after onboarding; agent-specific preset names are only labelled `[from agent]` when the active agent matches.
-Claude Code direct egress is not included in any policy tier.
-If you install and run the Claude Code CLI inside the sandbox with its own credentials, apply the `claude-code` preset explicitly.
+Claude Code direct egress is not included in the Restricted, Balanced, or Open tiers.
+The Personal tier selects the `claude-code` preset by default; on other tiers, apply it explicitly if you install and run the Claude Code CLI inside the sandbox with its own credentials.
Normal NemoClaw Anthropic inference still routes through the OpenShell gateway.
Tier definitions are stored in `nemoclaw-blueprint/policies/tiers.yaml`.
@@ -217,6 +235,7 @@ For a safe host binding, policy recovery commands, and a runnable collector, ref
## Operator Approval Flow
When the agent attempts to reach an endpoint not listed in the policy, OpenShell intercepts the request and presents it in the TUI for operator review.
+The Personal tier does not prompt for matching TCP connections on destination ports `80` or `443` because `personal-open-internet` already permits them.
The flow has these steps:
1. The agent makes a network request to an unlisted host.
diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx
index b05ecba339f..c77e4b46c0f 100644
--- a/docs/security/best-practices.mdx
+++ b/docs/security/best-practices.mdx
@@ -89,7 +89,8 @@ flowchart TB
## Network Controls
NemoClaw controls which hosts, ports, and HTTP methods the sandbox can reach, and lets you approve or deny requests in real time.
-Network policy allowlists do not disable OpenShell's SSRF guard, so internal-address blocking remains active even when an endpoint matches an egress rule.
+OpenShell hard-blocks unspecified, loopback, and link-local destinations, including the common cloud metadata range.
+An endpoint with `allowed_ips` can explicitly permit other private ranges, so treat that field as a server-side request forgery (SSRF) boundary change.
OpenShell provides additional network enforcement mechanisms not covered here, including network namespace isolation, SSRF protection, TLS auto-detection and termination, and audit-vs-enforce modes.
Refer to the [Network Controls](https://docs.nvidia.com/openshell/latest/security/best-practices.html#network-controls) section of the OpenShell Security Best Practices.
@@ -222,6 +223,7 @@ NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for
| `local-inference` | Local Ollama and vLLM through the host gateway. | Allows sandbox access to host-side local inference ports covered by the preset. |
| `npm` | npm and Yarn registries via L4 pass-through. | Allows installing arbitrary npm packages, which may contain malicious code. OpenShell still gates by host, port, and binary, but does not inspect HTTP method, path, or body for this preset. |
| `outlook` | Microsoft 365, Outlook. | Gives agent access to email. |
+| `personal-open-internet` | TCP connections to public and private address ranges on destination ports `80` and `443` from every sandbox binary. | Removes hostname, binary, application protocol, HTTP method, path, and request-body restrictions for matching connections. An agent can send sandbox-visible data to an arbitrary reachable service on either port without another approval prompt. |
| `pypi` | Python Package Index (GET and HEAD only). | Allows installing arbitrary Python packages, which may contain malicious code. Publishing is blocked. |
| `slack` | Slack API, Socket Mode, webhooks. | WebSocket uses `access: full`. Agent can post to any channel the bot token has access to. |
| `tavily` | Tavily Search API. | Agent can submit search queries and extraction targets to Tavily. The preset allows only `POST /search` and `POST /extract` from the maintained agent runtimes and enables request-body credential rewriting when the agent sends the placeholder in JSON. |
@@ -230,6 +232,16 @@ NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for
Apply presets only when the agent's task requires the integration.
Review the preset's YAML file before applying to understand the endpoints, methods, and binary restrictions it adds.
+
+The Personal tier selects `personal-open-internet` and every maintained preset supported by the active agent.
+The open-internet preset allows every sandbox binary to reach public and private address ranges on destination ports `80` and `443` through L4 passthrough.
+Traffic on those ports is not limited to HTTP or HTTPS.
+OpenShell does not inspect the hostname, application protocol, HTTP method, path, or body for those connections.
+The preset excludes unspecified, loopback, and link-local ranges, and other ports remain denied unless another entry permits them.
+Use it only for trusted personal workloads with trusted prompts and data.
+The sandbox's filesystem, process, gateway authentication, and managed credential controls remain active.
+
+
### Web Search Credential Rewriting
NemoClaw registers each selected web search credential in a sandbox-scoped OpenShell provider and writes a resolver placeholder into the agent configuration.
@@ -898,6 +910,15 @@ Use when the agent needs package registries, Docker Hub, or broader GitHub acces
- Review the agent's network activity periodically with `openshell term`.
- Use operator approval for any endpoint not covered by a preset.
+### Personal
+
+Use only for a trusted single-user sandbox that needs arbitrary TCP egress on destination ports `80` and `443`.
+
+- Select the Personal tier during onboarding.
+- Treat every prompt, downloaded package, webpage, and workspace file as able to trigger external TCP traffic on destination ports `80` and `443`.
+- Do not place raw credentials or sensitive data in the sandbox unless the agent must use them.
+- Return to Balanced or Restricted and recreate the sandbox when this broad egress is no longer required.
+
### Integration Testing
Use when the agent talks to internal APIs or third-party services during testing.
diff --git a/nemoclaw-blueprint/policies/presets/personal-open-internet.yaml b/nemoclaw-blueprint/policies/presets/personal-open-internet.yaml
new file mode 100644
index 00000000000..1c608429775
--- /dev/null
+++ b/nemoclaw-blueprint/policies/presets/personal-open-internet.yaml
@@ -0,0 +1,49 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+preset:
+ name: personal-open-internet
+ description: "Broad TCP egress on destination ports 80 and 443 for trusted personal sandboxes"
+
+network_policies:
+ personal_open_internet:
+ name: personal_open_internet
+ endpoints:
+ # OpenShell treats a hostless endpoint with allowed_ips as matching any
+ # requested host on these ports. It resolves the requested host and checks
+ # every address against the listed ranges. Omitting protocol keeps the
+ # connection at L4, without application protocol, HTTP method, or path
+ # inspection.
+ - ports: [80, 443]
+ allowed_ips:
+ - 1.0.0.0/8
+ - 2.0.0.0/7
+ - 4.0.0.0/6
+ - 8.0.0.0/5
+ - 16.0.0.0/4
+ - 32.0.0.0/3
+ - 64.0.0.0/3
+ - 96.0.0.0/4
+ - 112.0.0.0/5
+ - 120.0.0.0/6
+ - 124.0.0.0/7
+ - 126.0.0.0/8
+ - 128.0.0.0/3
+ - 160.0.0.0/5
+ - 168.0.0.0/8
+ - 169.0.0.0/9
+ - 169.128.0.0/10
+ - 169.192.0.0/11
+ - 169.224.0.0/12
+ - 169.240.0.0/13
+ - 169.248.0.0/14
+ - 169.252.0.0/15
+ - 169.255.0.0/16
+ - 170.0.0.0/7
+ - 172.0.0.0/6
+ - 176.0.0.0/4
+ - 192.0.0.0/2
+ - 2000::/3
+ - fc00::/7
+ binaries:
+ - { path: "/**" }
diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml
index 541c30a3576..97d321887ab 100644
--- a/nemoclaw-blueprint/policies/tiers.yaml
+++ b/nemoclaw-blueprint/policies/tiers.yaml
@@ -9,7 +9,10 @@
#
# access values:
# read — GET only (or equivalent read-only rules for that service)
-# read-write — GET + POST/PUT/PATCH (explicitly opt-in; never the default)
+# read-write — GET + POST/PUT/PATCH (must be explicit in the tier definition)
+#
+# A preset can define stricter or broader native OpenShell behavior than this
+# display-level access label. The Personal internet preset uses L4 passthrough.
tiers:
- name: restricted
@@ -46,3 +49,38 @@ tiers:
- { name: teams, access: read-write }
- { name: jira, access: read-write }
- { name: outlook, access: read-write }
+
+ - name: personal
+ label: Personal
+ description: Trusted personal-use posture. Lets every sandbox binary open TCP connections to public and private address ranges on destination ports 80 and 443. Unspecified, loopback, and link-local ranges remain blocked. Also enables every maintained preset that applies to the selected agent. Do not use with untrusted prompts or data.
+ presets:
+ - { name: personal-open-internet, access: read-write }
+ - { name: npm, access: read-write }
+ - { name: pypi, access: read-write }
+ - { name: huggingface, access: read-write }
+ - { name: brew, access: read-write }
+ - { name: brave, access: read-write }
+ - { name: tavily, access: read-write }
+ - { name: weather, access: read-write }
+ - { name: public-reference, access: read-write }
+ - { name: github, access: read-write }
+ - { name: gmail, access: read-write }
+ - { name: jira, access: read-write }
+ - { name: outlook, access: read-write }
+ - { name: claude-code, access: read-write }
+ - { name: local-inference, access: read-write }
+ - { name: openclaw-pricing, access: read-write }
+ - { name: openclaw-diagnostics-otel-local, access: read-write }
+ - { name: observability-otlp-local, access: read-write }
+ - { name: nous-web, access: read-write }
+ - { name: nous-image, access: read-write }
+ - { name: nous-audio, access: read-write }
+ - { name: nous-browser, access: read-write }
+ - { name: nous-code, access: read-write }
+ - { name: slack, access: read-write }
+ - { name: discord, access: read-write }
+ - { name: telegram, access: read-write }
+ - { name: googlechat, access: read-write }
+ - { name: wechat, access: read-write }
+ - { name: whatsapp, access: read-write }
+ - { name: teams, access: read-write }
diff --git a/schemas/network-policy.schema.json b/schemas/network-policy.schema.json
index cc7e94c6f96..9c53f4db13e 100644
--- a/schemas/network-policy.schema.json
+++ b/schemas/network-policy.schema.json
@@ -37,19 +37,26 @@
},
"endpoint": {
"type": "object",
- "required": [
- "host",
- "port"
- ],
"properties": {
"host": {
- "type": "string"
+ "type": "string",
+ "minLength": 1
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
+ "ports": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ },
+ "minItems": 1,
+ "uniqueItems": true
+ },
"path": {
"type": "string",
"pattern": "^/"
@@ -125,25 +132,52 @@
},
"allOf": [
{
- "if": {
- "anyOf": [
- {
- "not": {
- "required": [
- "protocol"
- ]
- }
- },
- {
- "properties": {
- "protocol": {
- "const": "rest"
- }
- },
+ "oneOf": [
+ {
+ "required": [
+ "port"
+ ],
+ "not": {
"required": [
- "protocol"
+ "ports"
]
}
+ },
+ {
+ "required": [
+ "ports"
+ ],
+ "not": {
+ "required": [
+ "port"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "required": [
+ "host"
+ ]
+ },
+ {
+ "required": [
+ "allowed_ips"
+ ]
+ }
+ ]
+ },
+ {
+ "if": {
+ "properties": {
+ "protocol": {
+ "const": "rest"
+ }
+ },
+ "required": [
+ "protocol"
]
},
"then": {
diff --git a/scripts/install.sh b/scripts/install.sh
index 7fce1a40af5..afae890105b 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -3769,7 +3769,7 @@ validate_station_express_resume_sandbox() {
validate_station_express_resume_policy_tier() {
case "${1:-}" in
- restricted | balanced | open) return 0 ;;
+ restricted | balanced | open | personal) return 0 ;;
*) return 1 ;;
esac
}
@@ -4805,6 +4805,10 @@ describe_express_install() {
policy_summary="base sandbox policy plus broad third-party presets"
policy_summary="${policy_summary}, and local-inference access when needed"
;;
+ personal)
+ policy_summary="base sandbox policy plus TCP egress from every sandbox binary to public and private address ranges on destination ports 80 and 443"
+ policy_summary="${policy_summary} (excluding unspecified, loopback, and link-local ranges), every maintained applicable preset, and local-inference access when needed"
+ ;;
*)
policy_summary="base sandbox policy plus tier presets supported by the active agent"
policy_summary="${policy_summary}, and local-inference access when needed"
diff --git a/src/lib/inference/serving/resolver.test.ts b/src/lib/inference/serving/resolver.test.ts
index d10b45f022a..0079cf3dd78 100644
--- a/src/lib/inference/serving/resolver.test.ts
+++ b/src/lib/inference/serving/resolver.test.ts
@@ -204,9 +204,13 @@ function storageRemediableReadinessReport(
...report.capabilities.map((capability) =>
capability.id === "host.docker.storage_compatible"
? { ...capability, state: "absent" as const }
- : capability,
+ : capability.id === "host.docker.storage_remediation_available"
+ ? { ...capability, state: "present" as const }
+ : capability,
),
- { id: "host.docker.storage_remediation_available", state: "present" },
+ ...(report.capabilities.some(({ id }) => id === "host.docker.storage_remediation_available")
+ ? []
+ : [{ id: "host.docker.storage_remediation_available", state: "present" as const }]),
],
findings: [
{
diff --git a/src/lib/inference/serving/resolver.ts b/src/lib/inference/serving/resolver.ts
index f8f06d83d58..9398b3253d7 100644
--- a/src/lib/inference/serving/resolver.ts
+++ b/src/lib/inference/serving/resolver.ts
@@ -198,7 +198,7 @@ function readinessScopeMatches(
function compareNumericDottedVersions(left: string, right: string): number | undefined {
const parse = (value: string): number[] | undefined => {
- if (!/^\d+(?:\.\d+)+$/u.test(value)) return undefined;
+ if (!/^\d+(?:\.\d+)*$/u.test(value)) return undefined;
const parts = value.split(".").map(Number);
return parts.every(Number.isSafeInteger) ? parts : undefined;
};
diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts
index 38943f9fe8e..1cf006d0ca0 100644
--- a/src/lib/onboard/command.test.ts
+++ b/src/lib/onboard/command.test.ts
@@ -433,6 +433,9 @@ describe("onboard command options", () => {
NEMOCLAW_PROVIDER: "previous-provider",
NEMOCLAW_MODEL: "previous-model",
NEMOCLAW_OLLAMA_NO_AUTOSTART: "0",
+ NEMOCLAW_POLICY_MODE: "previous-mode",
+ NEMOCLAW_POLICY_TIER: "previous-tier",
+ NEMOCLAW_TOOL_DISCLOSURE: "progressive",
};
const observed: Record = {};
await runOnboardCommand({
@@ -444,6 +447,9 @@ describe("onboard command options", () => {
"NEMOCLAW_PROVIDER",
"NEMOCLAW_MODEL",
"NEMOCLAW_OLLAMA_NO_AUTOSTART",
+ "NEMOCLAW_POLICY_MODE",
+ "NEMOCLAW_POLICY_TIER",
+ "NEMOCLAW_TOOL_DISCLOSURE",
]) {
observed[key] = env[key];
}
@@ -455,12 +461,18 @@ describe("onboard command options", () => {
NEMOCLAW_PROVIDER: "ollama",
NEMOCLAW_MODEL: "qwen3-vl:4b",
NEMOCLAW_OLLAMA_NO_AUTOSTART: "1",
+ NEMOCLAW_POLICY_MODE: "suggested",
+ NEMOCLAW_POLICY_TIER: "personal",
+ NEMOCLAW_TOOL_DISCLOSURE: "direct",
});
expect(env).toMatchObject({
NEMOCLAW_EXPERIMENTAL_PROFILE: "previous-profile",
NEMOCLAW_PROVIDER: "previous-provider",
NEMOCLAW_MODEL: "previous-model",
NEMOCLAW_OLLAMA_NO_AUTOSTART: "0",
+ NEMOCLAW_POLICY_MODE: "previous-mode",
+ NEMOCLAW_POLICY_TIER: "previous-tier",
+ NEMOCLAW_TOOL_DISCLOSURE: "progressive",
});
});
diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts
index 31e5fbbdab3..7cf9e0be74d 100644
--- a/src/lib/onboard/command.ts
+++ b/src/lib/onboard/command.ts
@@ -371,9 +371,12 @@ function applyPortableEnvironment(
if (!options.experimentalProfile) return () => {};
const portableEnvDefaults = {
[EXPERIMENTAL_PROFILE_ENV]: options.experimentalProfile ?? undefined,
+ [TOOL_DISCLOSURE_ENV]: "direct",
NEMOCLAW_PROVIDER: "ollama",
NEMOCLAW_MODEL: "qwen3-vl:4b",
NEMOCLAW_OLLAMA_NO_AUTOSTART: "1",
+ NEMOCLAW_POLICY_MODE: "suggested",
+ NEMOCLAW_POLICY_TIER: "personal",
} as const;
const previousPortableEnv = new Map();
const restore = () => {
@@ -407,6 +410,15 @@ function applyServingProfileEnvironment(
};
}
+function toolDisclosureEnvironmentOverride(
+ options: OnboardCommandOptions,
+ flags: OnboardFlags,
+): ToolDisclosure | null {
+ if (!options.toolDisclosure) return null;
+ if (!options.experimentalProfile) return options.toolDisclosure;
+ return flags["tool-disclosure"] !== undefined ? options.toolDisclosure : null;
+}
+
export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise {
const options = resolveOnboardOptions(deps.flags, deps);
const env = deps.env ?? process.env;
@@ -420,7 +432,8 @@ export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise([
+ "brave",
"openclaw-pricing",
OPENCLAW_OTEL_LOCAL_POLICY_PRESET,
]);
diff --git a/src/lib/onboard/policy-selection-prompts.ts b/src/lib/onboard/policy-selection-prompts.ts
index 24890e33044..ac66acca6c7 100644
--- a/src/lib/onboard/policy-selection-prompts.ts
+++ b/src/lib/onboard/policy-selection-prompts.ts
@@ -77,7 +77,7 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe
const processEvents = deps.processEvents ?? process;
/**
- * Prompt the user to select a policy tier (restricted / balanced / open).
+ * Prompt the user to select a policy tier (restricted / balanced / open / personal).
* Uses the same radio-style TUI as presetsCheckboxSelector (single-select).
* In non-interactive mode reads NEMOCLAW_POLICY_TIER (default: balanced).
* Returns the tier name string.
diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts
index ca3fb31c723..505b7030425 100644
--- a/src/lib/onboard/policy-selection.ts
+++ b/src/lib/onboard/policy-selection.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search";
+import { PERSONAL_POLICY_TIER_NAME } from "../policy/tiers";
import {
filterSetupPolicyPresetNamesForAgent,
filterSetupPolicyPresetsForAgent,
@@ -135,12 +136,14 @@ export function computeSetupPresetSuggestions(
} = options;
const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null;
const supportOptions = { webSearchSupported: options.webSearchSupported };
+ const preservesAllWebSearchPresets = tierName === PERSONAL_POLICY_TIER_NAME;
const suggestions = deps.tiers
.resolveTierPresets(tierName)
.map((preset) => preset.name)
.filter((name) => setupPolicyPresetAppliesToAgent(name, agent))
.filter(
(name) =>
+ preservesAllWebSearchPresets ||
!isStaleBuiltinWebSearchPolicyPreset(name, {
webSearchConfig,
customPresetNames: options.customPresetNames,
@@ -211,8 +214,8 @@ export function computeSetupPresetSuggestions(
}
export {
- preparePolicyPresetResumeSelection,
type PreparedPolicyResumeSelection,
+ preparePolicyPresetResumeSelection,
} from "./policy-resume-selection";
export async function setupPoliciesWithSelection(
@@ -366,6 +369,7 @@ async function setupPoliciesWithSelectionInner(
const tierName = recordedTierName ?? (await deps.selectPolicyTier());
deps.setPolicyTier?.(sandboxName, tierName);
+ const personalTier = tierName === PERSONAL_POLICY_TIER_NAME;
const suggestions = pruneUnavailablePresets(
computeSetupPresetSuggestions(deps, tierName, {
enabledChannels,
@@ -380,6 +384,7 @@ async function setupPoliciesWithSelectionInner(
hermesToolGateways,
env: deps.env,
}),
+ { preserveExplicitWebSearch: personalTier },
);
const suppressedNames = emitSuppressedAgentRequiredPresetsNote(tierName, agent, deps.note);
@@ -432,7 +437,7 @@ async function setupPoliciesWithSelectionInner(
customOwnsObservability,
});
chosen = pruneUnavailablePresets(chosen, {
- preserveExplicitWebSearch: isAuthoritative,
+ preserveExplicitWebSearch: isAuthoritative || personalTier,
});
const invalidPresets = chosen.filter((name) => !knownPresets.has(name));
diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts
index 8475c8a318d..fec1038a78e 100644
--- a/src/lib/onboard/sandbox-create-plan.test.ts
+++ b/src/lib/onboard/sandbox-create-plan.test.ts
@@ -8,6 +8,7 @@ import {
prepareSandboxCreatePlan,
resolveSandboxCreateIntent,
resolveSandboxCreateMessagingProviderRequests,
+ resolveSandboxCreatePolicyTier,
} from "./sandbox-create-plan";
import type { SandboxGpuCreateConfig } from "./sandbox-gpu-create";
@@ -403,6 +404,13 @@ describe("resolveSandboxCreateIntent", () => {
});
describe("prepareSandboxCreatePlan", () => {
+ it("recognizes Personal as a create-time policy tier", () => {
+ vi.stubEnv("NEMOCLAW_NON_INTERACTIVE", "1");
+ vi.stubEnv("NEMOCLAW_POLICY_TIER", "personal");
+
+ expect(resolveSandboxCreatePolicyTier()).toBe("personal");
+ });
+
it("builds create args, policy, providers, and active channels in onboard order", () => {
vi.stubEnv("NEMOCLAW_NON_INTERACTIVE", "1");
vi.stubEnv("NEMOCLAW_POLICY_TIER", "restricted");
diff --git a/src/lib/onboard/sandbox-create-plan.ts b/src/lib/onboard/sandbox-create-plan.ts
index b2e7a88d704..b4b556d9a99 100644
--- a/src/lib/onboard/sandbox-create-plan.ts
+++ b/src/lib/onboard/sandbox-create-plan.ts
@@ -39,7 +39,7 @@ export {
// tests). The list mirrors `nemoclaw-blueprint/policies/tiers.yaml`; adding a
// tier there requires updating this set so an explicit tier env value reaches
// the create-time policy decision.
-const KNOWN_POLICY_TIER_NAMES = new Set(["restricted", "balanced", "open"]);
+const KNOWN_POLICY_TIER_NAMES = new Set(["restricted", "balanced", "open", "personal"]);
export function resolveSandboxCreatePolicyTier(
authoritativePolicyTier?: string | null,
diff --git a/src/lib/onboard/station-express-resume.test.ts b/src/lib/onboard/station-express-resume.test.ts
index d6b1a0683e0..3c4e84c3274 100644
--- a/src/lib/onboard/station-express-resume.test.ts
+++ b/src/lib/onboard/station-express-resume.test.ts
@@ -768,6 +768,22 @@ describe("DGX Station Express resume (#7048)", () => {
}
});
+ it("accepts Personal as the policy tier in an installer resume receipt", () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-personal-tier-"));
+ const stateDir = path.join(home, ".nemoclaw");
+ const receipt = path.join(stateDir, "station-express-resume");
+ fs.mkdirSync(stateDir, { mode: 0o700 });
+ fs.writeFileSync(receipt, currentReceiptText({ policyTier: "personal" }), { mode: 0o600 });
+
+ try {
+ expect(() =>
+ assertStationExpressInstallerResumeMatches(receiptGeneration, { HOME: home }),
+ ).not.toThrow();
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ });
+
it.each([
["Nemotron Ultra", "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4"],
["DeepSeek V4 Flash", "deepseek-ai/DeepSeek-V4-Flash"],
diff --git a/src/lib/onboard/station-express-resume.ts b/src/lib/onboard/station-express-resume.ts
index 297530447e0..26907e7c5fb 100644
--- a/src/lib/onboard/station-express-resume.ts
+++ b/src/lib/onboard/station-express-resume.ts
@@ -111,7 +111,12 @@ const STATION_EXPRESS_RECEIPT_REVISION_PATTERN = /^[0-9a-f]{40}$/;
const STATION_EXPRESS_RETIREMENT_CLAIM_SUFFIX_PATTERN = /^[A-Za-z0-9]+$/;
const STATION_EXPRESS_RECEIPT_PORT_PATTERN = /^\d+$/;
const STATION_EXPRESS_RECEIPT_AGENTS = new Set(["openclaw", "hermes", "langchain-deepagents-code"]);
-const STATION_EXPRESS_RECEIPT_POLICY_TIERS = new Set(["restricted", "balanced", "open"]);
+const STATION_EXPRESS_RECEIPT_POLICY_TIERS = new Set([
+ "restricted",
+ "balanced",
+ "open",
+ "personal",
+]);
// Mirrors validate_station_install_mode() in scripts/install.sh.
const STATION_EXPRESS_RECEIPT_MODES = new Set(["express", "provider"]);
diff --git a/src/lib/policy/preset-allowed-ips.test.ts b/src/lib/policy/preset-allowed-ips.test.ts
index 35221522557..28392549272 100644
--- a/src/lib/policy/preset-allowed-ips.test.ts
+++ b/src/lib/policy/preset-allowed-ips.test.ts
@@ -227,4 +227,23 @@ network_policies:
}),
).toBe(false);
});
+
+ it("rejects a custom hostless endpoint with broad address ranges", () => {
+ const content = `\
+preset:
+ name: hostless-in-memory
+ description: broad hostless endpoint
+network_policies:
+ broad:
+ endpoints:
+ - ports: [80, 443]
+ allowed_ips:
+ - 1.0.0.0/8
+`;
+ expect(
+ applyPresetContent("test-sandbox", "hostless-in-memory", content, {
+ custom: { sourcePath: "hostless.yaml" },
+ }),
+ ).toBe(false);
+ });
});
diff --git a/src/lib/policy/preset-scope-render.test.ts b/src/lib/policy/preset-scope-render.test.ts
index 92be27e08bc..ec3f98388ec 100644
--- a/src/lib/policy/preset-scope-render.test.ts
+++ b/src/lib/policy/preset-scope-render.test.ts
@@ -62,6 +62,27 @@ describe("renderPresetScope (#7179)", () => {
expect(joined).toMatch(/allow:\s+POST\s+\/\*\*/);
});
+ it("discloses hostless allowed_ips endpoints instead of reporting no endpoints", () => {
+ const preset = `network_policies:
+ personal_open_internet:
+ name: personal_open_internet
+ endpoints:
+ - ports: [80, 443]
+ allowed_ips:
+ - 1.0.0.0/8
+ - 2000::/3
+ binaries:
+ - { path: "/**" }
+`;
+ const joined = renderPresetScope(preset).join("\n");
+
+ expect(joined).toContain("- :80,443");
+ expect(joined).toContain("allowed_ip: 1.0.0.0/8");
+ expect(joined).toContain("allowed_ip: 2000::/3");
+ expect(joined).toContain("- /**");
+ expect(joined).not.toContain("(no endpoints declared)");
+ });
+
it("surfaces the narrowly scoped Baileys version-fetch path, not just the host", () => {
const joined = renderPresetScope(WHATSAPP_LIKE_PRESET).join("\n");
expect(joined).toContain("raw.githubusercontent.com:443");
diff --git a/src/lib/policy/preset-scope-render.ts b/src/lib/policy/preset-scope-render.ts
index 4328af5ee69..6879dcf1605 100644
--- a/src/lib/policy/preset-scope-render.ts
+++ b/src/lib/policy/preset-scope-render.ts
@@ -13,12 +13,14 @@ type RuleScope = {
};
type EndpointScope = {
- host: string;
+ host?: string;
port?: number | string;
+ ports: Array;
protocol?: string;
access?: string;
tls?: string;
enforcement?: string;
+ allowedIps: string[];
rules: RuleScope[];
};
@@ -72,6 +74,14 @@ function toPortOrUndefined(value: unknown): number | string | undefined {
return undefined;
}
+function portArray(value: unknown): Array {
+ if (!Array.isArray(value)) return [];
+ return value.filter(
+ (port): port is number | string =>
+ typeof port === "number" || (typeof port === "string" && port.length > 0),
+ );
+}
+
function stringArray(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string");
if (typeof value === "string") return [value];
@@ -124,15 +134,18 @@ function extractPresetScope(content: string): PresetScope | null {
if (Array.isArray(rawEndpoints)) {
for (const rawEndpoint of rawEndpoints) {
if (!isObjectRecord(rawEndpoint)) continue;
- const host = typeof rawEndpoint.host === "string" ? rawEndpoint.host : null;
- if (!host) continue;
+ const host = typeof rawEndpoint.host === "string" ? rawEndpoint.host : undefined;
+ const allowedIps = stringArray(rawEndpoint.allowed_ips);
+ if (!host && allowedIps.length === 0) continue;
endpoints.push({
- host,
+ ...(host ? { host } : {}),
port: toPortOrUndefined(rawEndpoint.port),
+ ports: portArray(rawEndpoint.ports),
protocol: toStringOrUndefined(rawEndpoint.protocol as PolicyValue | undefined),
access: toStringOrUndefined(rawEndpoint.access as PolicyValue | undefined),
tls: toStringOrUndefined(rawEndpoint.tls as PolicyValue | undefined),
enforcement: toStringOrUndefined(rawEndpoint.enforcement as PolicyValue | undefined),
+ allowedIps,
rules: collectRules(rawEndpoint),
});
}
@@ -143,7 +156,11 @@ function extractPresetScope(content: string): PresetScope | null {
}
function formatEndpoint(endpoint: EndpointScope): string[] {
- const port = renderTerminalText(String(endpoint.port ?? "?"));
+ const host = endpoint.host
+ ? renderTerminalText(endpoint.host)
+ : "";
+ const portValue = endpoint.ports.length > 0 ? endpoint.ports.join(",") : (endpoint.port ?? "?");
+ const port = renderTerminalText(String(portValue));
const modeBits: string[] = [];
if (endpoint.access) modeBits.push(`access: ${renderTerminalText(endpoint.access)}`);
if (endpoint.protocol) modeBits.push(`protocol: ${renderTerminalText(endpoint.protocol)}`);
@@ -152,14 +169,17 @@ function formatEndpoint(endpoint: EndpointScope): string[] {
modeBits.push(`enforcement: ${renderTerminalText(endpoint.enforcement)}`);
}
const modeSuffix = modeBits.length > 0 ? ` (${modeBits.join(", ")})` : "";
- const header = ` - ${renderTerminalText(endpoint.host)}:${port}${modeSuffix}`;
- if (endpoint.rules.length === 0) return [header];
+ const header = ` - ${host}:${port}${modeSuffix}`;
+ const allowedIpLines = endpoint.allowedIps.map(
+ (allowedIp) => ` allowed_ip: ${renderTerminalText(allowedIp)}`,
+ );
+ if (endpoint.rules.length === 0) return [header, ...allowedIpLines];
const ruleLines = endpoint.rules.map((rule) => {
const methods = rule.methods.map(renderTerminalText).join(", ");
const paths = rule.paths.map(renderTerminalText).join(", ");
return ` ${rule.action}: ${methods} ${paths}`;
});
- return [header, ...ruleLines];
+ return [header, ...allowedIpLines, ...ruleLines];
}
export function renderPresetScope(
diff --git a/src/lib/policy/tiers.ts b/src/lib/policy/tiers.ts
index 18c3d6aae24..6ae5258a755 100644
--- a/src/lib/policy/tiers.ts
+++ b/src/lib/policy/tiers.ts
@@ -4,7 +4,7 @@
// Tier management — load tier definitions and resolve preset selections.
//
// Tiers are defined in nemoclaw-blueprint/policies/tiers.yaml.
-// Each tier is a named posture (restricted, balanced, open) that maps to
+// Each tier is a named posture (restricted, balanced, open, personal) that maps to
// a set of policy presets and their default access levels.
//
// The base sandbox policy is always applied regardless of tier.
@@ -18,6 +18,7 @@ import { isObjectRecord } from "../core/json-types";
import { ROOT } from "../runner";
const TIERS_FILE = path.join(ROOT, "nemoclaw-blueprint", "policies", "tiers.yaml");
+export const PERSONAL_POLICY_TIER_NAME = "personal";
const ALLOWED_ACCESS: ReadonlySet = new Set(["read", "read-write"]);
type TierAccess = "read" | "read-write";
diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts
index 06314442931..4a1f1072e8e 100644
--- a/test/onboard-policy-suggestions.test.ts
+++ b/test/onboard-policy-suggestions.test.ts
@@ -525,6 +525,7 @@ describe("onboard policy preset suggestions", () => {
it("keeps agent-specific policy presets out of the opposite agent selector", () => {
const allPresets = [
{ name: "weather" },
+ { name: "brave" },
{ name: "openclaw-pricing" },
{ name: "openclaw-diagnostics-otel-local" },
{ name: "observability-otlp-local" },
@@ -539,6 +540,7 @@ describe("onboard policy preset suggestions", () => {
]);
expect(filterSetupPolicyPresetsForAgent(allPresets, "openclaw").map((p) => p.name)).toEqual([
"weather",
+ "brave",
"openclaw-pricing",
"openclaw-diagnostics-otel-local",
]);
diff --git a/test/personal-open-internet-policy.test.ts b/test/personal-open-internet-policy.test.ts
new file mode 100644
index 00000000000..6d92b73286c
--- /dev/null
+++ b/test/personal-open-internet-policy.test.ts
@@ -0,0 +1,120 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { BlockList, isIP } from "node:net";
+import { describe, expect, it } from "vitest";
+import YAML from "yaml";
+
+import * as policies from "../src/lib/policy";
+
+type Endpoint = {
+ host?: string;
+ port?: number;
+ ports?: number[];
+ protocol?: string;
+ access?: string;
+ rules?: unknown[];
+ allowed_ips?: string[];
+};
+
+function loadPersonalInternetPolicy(): {
+ endpoints: Endpoint[];
+ binaries: Array<{ path?: string }>;
+} {
+ const content = policies.loadPreset("personal-open-internet");
+ expect(content).not.toBeNull();
+ const document = YAML.parse(content ?? "");
+ return document.network_policies.personal_open_internet;
+}
+
+function allowedAddressMatcher(cidrs: readonly string[]): (address: string) => boolean {
+ const allowed = new BlockList();
+ for (const cidr of cidrs) {
+ const [address, prefixText] = cidr.split("/");
+ const family = isIP(address ?? "");
+ const prefix = Number(prefixText);
+ expect(family, cidr).not.toBe(0);
+ expect(Number.isInteger(prefix), cidr).toBe(true);
+ allowed.addSubnet(address!, prefix, family === 4 ? "ipv4" : "ipv6");
+ }
+ return (address: string): boolean => {
+ const family = isIP(address);
+ return family !== 0 && allowed.check(address, family === 4 ? "ipv4" : "ipv6");
+ };
+}
+
+describe("Personal open internet policy preset", () => {
+ it("uses OpenShell hostless L4 matching on TCP ports 80 and 443 from every binary", () => {
+ const policy = loadPersonalInternetPolicy();
+
+ expect(policy.binaries).toEqual([{ path: "/**" }]);
+ expect(policy.endpoints).toHaveLength(1);
+ expect(policy.endpoints[0]?.ports).toEqual([80, 443]);
+ for (const endpoint of policy.endpoints) {
+ expect(endpoint.port).toBeUndefined();
+ expect(endpoint.host).toBeUndefined();
+ expect(endpoint.protocol).toBeUndefined();
+ expect(endpoint.access).toBeUndefined();
+ expect(endpoint.rules).toBeUndefined();
+ expect(endpoint.allowed_ips?.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("covers public and private networks without forbidden catch-all CIDRs", () => {
+ const policy = loadPersonalInternetPolicy();
+ const allowedIps = new Set(policy.endpoints[0]?.allowed_ips ?? []);
+
+ expect(allowedIps.size).toBe(29);
+ expect(allowedIps).toContain("1.0.0.0/8");
+ expect(allowedIps).toContain("172.0.0.0/6");
+ expect(allowedIps).toContain("192.0.0.0/2");
+ expect(allowedIps).toContain("2000::/3");
+ expect(allowedIps).toContain("fc00::/7");
+ expect(allowedIps).not.toContain("0.0.0.0/0");
+ expect(allowedIps).not.toContain("127.0.0.0/8");
+ expect(allowedIps).not.toContain("169.254.0.0/16");
+ expect(allowedIps).not.toContain("::/0");
+ });
+
+ it.each([
+ 80, 443,
+ ])("excludes normalized hard-blocked address classes at the connection boundary on port %i", (port) => {
+ const endpoint = loadPersonalInternetPolicy().endpoints[0]!;
+ expect(endpoint.ports).toContain(port);
+ const isAllowed = allowedAddressMatcher(endpoint.allowed_ips ?? []);
+
+ for (const address of [
+ "0.0.0.0",
+ "127.0.0.1",
+ "127.1.2.3",
+ "169.254.169.254",
+ "::",
+ "::1",
+ "0:0:0:0:0:0:0:1",
+ "fe80::1",
+ "::ffff:127.0.0.1",
+ "0:0:0:0:0:ffff:7f00:1",
+ "::ffff:169.254.169.254",
+ ]) {
+ expect(isAllowed(address), address).toBe(false);
+ }
+
+ for (const address of ["8.8.8.8", "10.0.0.1", "2001:4860:4860::8888", "fc00::1"]) {
+ expect(isAllowed(address), address).toBe(true);
+ }
+ });
+
+ it("composes unchanged into the create-time sandbox policy", () => {
+ const merged = policies.mergePresetNamesIntoPolicy("version: 1\nnetwork_policies: {}\n", [
+ "personal-open-internet",
+ ]);
+ const endpoint = YAML.parse(merged.policy).network_policies.personal_open_internet
+ .endpoints[0] as Endpoint;
+
+ expect(merged.appliedPresets).toEqual(["personal-open-internet"]);
+ expect(endpoint).toMatchObject({ ports: [80, 443] });
+ expect(endpoint.host).toBeUndefined();
+ expect(endpoint.protocol).toBeUndefined();
+ expect(endpoint.allowed_ips).toContain("192.0.0.0/2");
+ });
+});
diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts
index 4a9b278105f..eeec02aff90 100644
--- a/test/policy-tiers-onboard.test.ts
+++ b/test/policy-tiers-onboard.test.ts
@@ -259,7 +259,7 @@ process.exit = (code = 0) => {
assert.equal(payload.sessionExists, false, "onboard session must not be created");
assert.match(
result.stderr,
- /Unknown policy tier: invalid_tier\. Valid: restricted, balanced, open/,
+ /Unknown policy tier: invalid_tier\. Valid: restricted, balanced, open, personal/,
);
assert.doesNotMatch(result.stderr, /Third-Party Software Notice/);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /\[1\/8\] Preflight checks/);
@@ -370,7 +370,7 @@ describe("policy tier selection", () => {
assert.equal(exit.mock.calls[0]?.[0], 1);
assert.match(
errors.join("\n"),
- /Unknown policy tier: invalid_tier\. Valid: restricted, balanced, open/,
+ /Unknown policy tier: invalid_tier\. Valid: restricted, balanced, open, personal/,
);
});
@@ -446,6 +446,56 @@ describe("policy tier setup", () => {
assert.deepEqual(result.applied, []);
});
+ it("keeps OpenClaw web search and OpenClaw-only presets in Personal", async () => {
+ const result = await runPolicySetup(
+ { tierName: "personal" },
+ { agent: "openclaw", webSearchConfig: null, webSearchSupported: true },
+ );
+
+ for (const name of [
+ "personal-open-internet",
+ "brave",
+ "tavily",
+ "openclaw-pricing",
+ "openclaw-diagnostics-otel-local",
+ "googlechat",
+ ]) {
+ assert.ok(result.applied.includes(name), `${name} should be applied`);
+ }
+ assert.ok(!result.applied.includes("nous-web"), "Hermes-only presets must remain filtered");
+ assert.ok(
+ !result.applied.includes("observability-otlp-local"),
+ "Deep Agents-only presets must remain filtered",
+ );
+ });
+
+ it("keeps supported Hermes web search and Hermes-only presets in Personal", async () => {
+ const result = await runPolicySetup(
+ { tierName: "personal" },
+ { agent: "hermes", webSearchConfig: null, webSearchSupported: true },
+ );
+
+ for (const name of ["personal-open-internet", "tavily", "nous-web", "nous-browser"]) {
+ assert.ok(result.applied.includes(name), `${name} should be applied`);
+ }
+ assert.ok(!result.applied.includes("brave"), "unsupported Brave must remain filtered");
+ assert.ok(
+ !result.applied.includes("openclaw-pricing"),
+ "OpenClaw-only presets must remain filtered",
+ );
+ });
+
+ it("keeps open internet access in Personal for Deep Agents Code", async () => {
+ const result = await runPolicySetup(
+ { tierName: "personal" },
+ { agent: "langchain-deepagents-code", webSearchConfig: null, webSearchSupported: true },
+ );
+
+ assert.ok(result.applied.includes("personal-open-internet"));
+ assert.ok(result.applied.includes("tavily"));
+ assert.ok(!result.applied.includes("brave"));
+ });
+
it("omits Brave from policy preset selection when web search is unsupported", async () => {
const result = await runPolicySetup({ tierName: "balanced" }, { webSearchSupported: false });
diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts
index 6f5124b5cae..2c2bdc587ab 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(listTiers()).toHaveLength(3);
+ it("returns exactly 4 tiers", () => {
+ expect(listTiers()).toHaveLength(4);
});
- it("orders tiers as restricted, balanced, then open", () => {
+ it("orders tiers as restricted, balanced, open, then personal", () => {
const names = listTiers().map((tier: Tier) => tier.name);
- expect(names).toEqual(["restricted", "balanced", "open"]);
+ expect(names).toEqual(["restricted", "balanced", "open", "personal"]);
});
it("each tier has name, label, description, and presets array", () => {
@@ -87,7 +87,7 @@ describe("tiers", () => {
it("labels are human-readable capitalised strings", () => {
const labels = listTiers().map((tier: Tier) => tier.label);
- expect(labels).toEqual(["Restricted", "Balanced", "Open"]);
+ expect(labels).toEqual(["Restricted", "Balanced", "Open", "Personal"]);
});
});
@@ -107,6 +107,11 @@ describe("tiers", () => {
expect(tier.name).toBe("open");
});
+ it("returns the personal tier", () => {
+ const tier = mustGetTier("personal");
+ expect(tier.name).toBe("personal");
+ });
+
it("returns null for an unknown tier", () => {
expect(getTier("nonexistent")).toBeNull();
});
@@ -216,6 +221,22 @@ describe("tiers", () => {
});
});
+ describe("tier: personal", () => {
+ it("includes every maintained preset", () => {
+ const available = policies.listPresets().map((preset: Preset) => preset.name);
+ const personal = mustGetTier("personal").presets.map((preset: TierPreset) => preset.name);
+
+ expect(new Set(personal)).toEqual(new Set(available));
+ expect(personal).toHaveLength(available.length);
+ });
+
+ it("defaults every preset to read-write", () => {
+ for (const preset of mustGetTier("personal").presets) {
+ expect(preset.access).toBe("read-write");
+ }
+ });
+ });
+
describe("resolveTierPresets", () => {
it("returns default presets for balanced with no overrides", () => {
const resolved: TierPreset[] = resolveTierPresets("balanced");
diff --git a/test/validate-config-schemas.test.ts b/test/validate-config-schemas.test.ts
index 630e98e51b1..bd40ab95f78 100644
--- a/test/validate-config-schemas.test.ts
+++ b/test/validate-config-schemas.test.ts
@@ -271,6 +271,47 @@ describe("network-policy.schema.json", () => {
).toBe(true);
});
+ it("accepts an OpenShell hostless L4 endpoint with explicit address ranges", () => {
+ expect(
+ validate({
+ open_internet: {
+ name: "Open internet",
+ binaries: [{ path: "/**" }],
+ endpoints: [
+ {
+ ports: [80, 443],
+ allowed_ips: ["1.0.0.0/8", "2000::/3"],
+ },
+ ],
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it("rejects a hostless endpoint without explicit address ranges", () => {
+ expect(
+ validate({
+ invalid: {
+ name: "Invalid",
+ binaries: [{ path: "/**" }],
+ endpoints: [{ port: 443 }],
+ },
+ }),
+ ).toBe(false);
+ });
+
+ it("rejects an endpoint that declares both port forms", () => {
+ expect(
+ validate({
+ invalid: {
+ name: "Invalid",
+ binaries: [{ path: "/**" }],
+ endpoints: [{ host: "api.example.com", port: 443, ports: [80, 443] }],
+ },
+ }),
+ ).toBe(false);
+ });
+
it.each([
["an empty policy map", {}],
["an unrelated object", { unrelated: true }],