diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index a21e82991b7..014df9c4cb5 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -639,9 +639,11 @@ Four tiers are available: | 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. | +| Personal | Requires one broad web authority that lets every sandbox binary open TCP connections to public and private address ranges on destination ports 80 and 443. It replaces overlapping exact web endpoints while preserving non-web policy. Unspecified, loopback, and link-local ranges remain blocked. 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. +When Personal is selected or carried forward, `personal-open-internet` is mandatory for every agent and every onboarding entry point, including Portable. +The picker and policy modes control only additional presets; they cannot deselect, skip, or replace Personal's required web authority. For details on tiers and the presets each includes, refer to [Network Policies](network-policies#policy-tiers). When you finish the policy step, NemoClaw records the finalized built-in preset selection for that sandbox. When onboarding creates or recreates a sandbox with presets, NemoClaw prints the exact finalized create-time policy scope before registering providers or creating the sandbox. @@ -660,16 +662,18 @@ Interactive onboarding ignores an invalid environment value and shows the normal `NEMOCLAW_POLICY_MODE` controls how non-interactive onboarding reconciles the tier-derived suggestions against the sandbox's currently-applied presets. The default is `suggested`, which is *additive*. Onboarding applies tier defaults and preserves any presets you previously added with [`$$nemoclaw policy add`](#$$nemoclaw-name-policy-add) across re-onboards. -Use `custom` with `NEMOCLAW_POLICY_PRESETS` when you want the explicit list to be authoritative. -Onboarding removes any preset that is not in the list. -`skip` leaves the applied set untouched and does not apply tier defaults. +Use `custom` with `NEMOCLAW_POLICY_PRESETS` when you want the explicit list to be authoritative for optional presets. +Onboarding removes any optional preset that is not in the list. +`skip` does not add optional tier defaults and retains eligible optional presets already applied. +For Personal, all modes still apply or retain the mandatory `personal-open-internet` preset. 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. +The Personal tier instead uses `personal-open-internet` for web transport and does not select Brave Search or Tavily Search merely to enable ordinary web fetches. +This makes keyless fetches available to any sandbox binary, but it does not add a provider-free `web_search` implementation. For Hermes, this includes replacing stale `nous-web` when Tavily is selected. -An explicit `custom` preset list or interactive manual selection remains operator-controlled. +An explicit `custom` preset list or interactive manual selection remains operator-controlled for additional presets. @@ -681,8 +685,8 @@ When Tavily Search is selected, it replaces `nous-web` as the Hermes web search | Value | Behaviour | |-------|-----------| | `suggested` (default) | Apply tier defaults and preserve any extra presets already applied. Aliases: `default`, `auto`. | -| `custom` | Apply exactly `NEMOCLAW_POLICY_PRESETS`. Previously-applied presets not in the list are removed. Alias: `list`. | -| `skip` | Skip the policy step entirely. Aliases: `none`, `no`. | +| `custom` | Apply exactly the optional presets in `NEMOCLAW_POLICY_PRESETS`. Previously-applied optional presets not in the list are removed. Personal still requires `personal-open-internet`. Alias: `list`. | +| `skip` | Do not add optional tier defaults; retain eligible optional presets already applied. Personal still applies or retains `personal-open-internet`. Aliases: `none`, `no`. | diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 2c3ef396061..b9607cd9c96 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -139,7 +139,10 @@ 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. | +| Personal | `personal-open-internet` (mandatory) | Lets every sandbox binary open TCP connections to public and private address ranges on destination ports `80` and `443`. The broad route replaces overlapping web endpoints while preserving non-web policy. Unspecified, loopback, and link-local ranges remain blocked. | + +When Personal is selected or carried forward, the `personal-open-internet` preset is mandatory for every agent and every onboarding entry point. +Interactive choices, `NEMOCLAW_POLICY_MODE=custom`, and `NEMOCLAW_POLICY_MODE=skip` control only additional presets; they cannot deselect, skip, or replace Personal's required web authority. The Personal tier applies the `personal-open-internet` policy preset with a hostless L4 endpoint on destination ports `80` and `443`. @@ -155,12 +158,13 @@ The sandbox's filesystem, process, gateway authentication, and managed credentia Use this tier only for trusted personal workloads with trusted prompts and data. -For a fresh onboarding run, the experimental portable profile selects `weather`, `public-reference`, and `github` when `NEMOCLAW_POLICY_PRESETS` is unset, blank, or contains only whitespace. -This default does not select `personal-open-internet`. -If `NEMOCLAW_POLICY_PRESETS` contains a non-blank list, portable onboarding treats that explicit list as authoritative instead. -`$$nemoclaw onboard --resume` does not inject the fresh-onboarding default. +Every fresh onboarding run through the experimental Portable profile selects the Personal tier. +When `NEMOCLAW_POLICY_PRESETS` is unset, blank, or contains only whitespace, Portable uses `suggested` mode with no optional preset override. +If `NEMOCLAW_POLICY_PRESETS` contains a non-blank list, Portable treats that list as authoritative for additional presets while still applying mandatory `personal-open-internet`. +`$$nemoclaw onboard --resume` does not override a recorded non-Personal tier; a resumed Personal tier retains or repairs its mandatory preset. -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. +After selecting a tier, a combined preset and access-mode screen lets you include or exclude optional presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access. +On Personal, NemoClaw restores `personal-open-internet` if it is deselected in the screen. 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. @@ -182,16 +186,17 @@ These modes skip policy preset additions, but they do not preserve a disabled ch `$$nemoclaw onboard --resume` reconciles the policy selection instead of skipping it when the effective messaging selection omits a channel whose preset remains applied. NemoClaw keeps the preset for an in-sandbox QR-paired channel such as WhatsApp because you pair that channel inside the sandbox rather than through host environment values. -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. +The Personal tier does not select a Brave Search or Tavily Search preset by default. +Its broad route supports ordinary keyless web fetches, while `web_search` still requires a separately configured provider. +Explicit custom preset lists and manual interactive selections remain operator-controlled for additional presets. 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. When Hermes uses Tavily, NemoClaw removes `nous-web` from the effective managed-tool selection while preserving other selected Nous tool presets. -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. -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. +OpenClaw onboarding also adds the `openclaw-pricing` preset on top of Balanced and Open tier defaults so session-cost records can populate from LiteLLM and OpenRouter without manual configuration. +On the Balanced, Open, and Personal tiers, enabling OpenClaw OTEL diagnostics with a local endpoint adds the `openclaw-diagnostics-otel-local` preset. +Personal suppresses the overlapping pricing route because `personal-open-internet` already owns ports `80` and `443`, while Restricted suppresses it to retain the restricted posture. When LangChain Deep Agents Code is onboarded with `--observability`, NemoClaw adds the `observability-otlp-local` preset on Balanced, Open, and Personal tiers. @@ -202,7 +207,7 @@ The applied set therefore reflects the chosen tier *plus* any agent-required pre 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 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. +Personal's broad route permits its port `443` transport but does not install or configure Claude Code; on other tiers, apply the `claude-code` preset explicitly if you install and run the 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`. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 6276fe1e053..9d483ebfdc4 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -241,7 +241,7 @@ The preset separately grants that registry transport to its listed npm, Yarn, an -The Personal tier selects `personal-open-internet` and every maintained preset supported by the active agent. +The Personal tier selects `personal-open-internet` as its web authority and preserves non-web policy. 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. @@ -372,7 +372,7 @@ Use only for a trusted single-user sandbox that needs arbitrary TCP egress on de - 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. +- Create a new Balanced or Restricted sandbox when this broad egress is no longer required. NemoClaw refuses to remove Personal in place because its normalized policy has already replaced overlapping web entries. ### Integration Testing diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 1f9d494555f..c35ebc7113f 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -3,9 +3,9 @@ # # Tier definitions for the NemoClaw policy selector. # -# Each tier maps to a named posture. The base sandbox policy -# (openclaw-sandbox.yaml) is always applied regardless of tier. -# Tiers add presets on top of that baseline. +# Each tier maps to a named posture. The base sandbox policy is always the +# starting point. Personal then replaces overlapping port 80/443 endpoints +# with its single broad L4 authority while preserving non-web policy. # # access values: # read — GET only (or equivalent read-only rules for that service) @@ -52,36 +52,6 @@ tiers: - 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. + 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. This broad route replaces overlapping web endpoints while active. 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: local-memory, 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/src/lib/actions/sandbox/rebuild-backup-phase.test.ts b/src/lib/actions/sandbox/rebuild-backup-phase.test.ts index 5f33475e81a..2d0a8ad2ee2 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.test.ts @@ -195,6 +195,19 @@ describe("rebuild web-search policy normalization", () => { ), ).toEqual(["npm", "future-agent-required"]); }); + + it.each(["openclaw", "hermes", "langchain-deepagents-code", "pi"] as const)( + "repairs a Personal rebuild target missing its tier-defining preset: %s", + (agent) => { + expect( + normalizeRebuildTargetPolicyPresets( + ["npm"], + { name: "alpha", agent, policyTier: " Personal " }, + null, + ), + ).toEqual(["personal-open-internet", "npm"]); + }, + ); }); describe("custom OpenClaw plugin provenance rebuild guard (#6108)", () => { diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.ts b/src/lib/actions/sandbox/rebuild-backup-phase.ts index 8bb24655873..f141490bb13 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.ts @@ -12,7 +12,10 @@ import { } from "../../onboard/observability-policy-presets"; import { resolveRecreatePolicyPresets } from "../../onboard/policy-preset-persistence"; import { isStaleBuiltinWebSearchPolicyPreset } from "../../onboard/policy-selection"; -import { filterSuppressedAgentRequiredPresets } from "../../onboard/policy-tier-suppression"; +import { + ensureRequiredTierPolicyPresets, + filterSuppressedAgentRequiredPresets, +} from "../../onboard/policy-tier-suppression"; import { parsePresetPolicyKeys } from "../../policy"; import { hasCompleteOpenClawImagePluginProvenance } from "../../state/openclaw-plugin-restore"; import { hasAuthoritativeOpenClawImagePluginProvenance } from "../../state/sandbox"; @@ -138,9 +141,12 @@ export function normalizeRebuildTargetPolicyPresets( sandboxEntry: RebuildSandboxEntry, webSearchConfig: WebSearchConfig | null, ): string[] { - return normalizeRebuildObservabilityPolicyPresets( - normalizeRebuildWebSearchPolicyPresets([...new Set(presets)], sandboxEntry, webSearchConfig), - sandboxEntry, + return ensureRequiredTierPolicyPresets( + sandboxEntry.policyTier, + normalizeRebuildObservabilityPolicyPresets( + normalizeRebuildWebSearchPolicyPresets([...new Set(presets)], sandboxEntry, webSearchConfig), + sandboxEntry, + ), ); } diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index a54b20a3029..5316dbb51fb 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -251,8 +251,8 @@ export async function handlePoliciesState({ // runs syncPresetSelection (signalled by onSelection firing) qualifies: // - the ordinary skip path (NEMOCLAW_POLICY_MODE=skip/none/no) returns [] // without touching the live set, so persisting [] would wipe real - // policies. A skip with excluded presets instead reconciles and persists - // the retained live set; + // policies. A skip with exclusions or a missing tier-defining preset + // instead reconciles and persists the retained live set; // - the resume path only checks recorded presets are a *subset* of what's // applied (arePolicyPresetsApplied), not that the live set matches — an // interrupted prior run may still have extra applied presets (e.g. an @@ -319,8 +319,9 @@ export async function handlePoliciesState({ // interruption can't leave a completed-resumable session without the // finalized marker (--resume would then skip the persist permanently). // Skipped only when no reconciliation occurred (including ordinary skip - // without exclusions), which leaves the live applied set untouched and - // would otherwise be clobbered with []. See #4621. + // without exclusions or a missing tier requirement), which leaves the live + // applied set untouched and would otherwise be clobbered with []. See + // #4621. if (reflectsLiveAppliedSet) { deps.persistAppliedPolicyPresets(sandboxName, appliedPolicyPresets); } diff --git a/src/lib/onboard/policy-preset-reconciliation.ts b/src/lib/onboard/policy-preset-reconciliation.ts index e8471ee58bb..a7df501f184 100644 --- a/src/lib/onboard/policy-preset-reconciliation.ts +++ b/src/lib/onboard/policy-preset-reconciliation.ts @@ -14,7 +14,10 @@ import { } from "./observability-policy-presets"; import { mergeRequiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { classifyPresetProvenance } from "../policy/preset-provenance"; -import { filterSuppressedAgentRequiredPresets } from "./policy-tier-suppression"; +import { + ensureRequiredTierPolicyPresets, + filterSuppressedAgentRequiredPresets, +} from "./policy-tier-suppression"; export type RequiredSetupPolicyPresetOptions = { enabledChannels?: string[] | null; @@ -77,7 +80,10 @@ export function mergeRequiredSetupPolicyPresets( }, ); const agentScoped = filterSetupPolicyPresetNamesForAgent(mergedPresets, options.agent); - return filterSuppressedAgentRequiredPresets(agentScoped, options.tierName, options.agent); + return ensureRequiredTierPolicyPresets( + options.tierName, + filterSuppressedAgentRequiredPresets(agentScoped, options.tierName, options.agent), + ); } export function isStaleBuiltinBravePolicyPreset( diff --git a/src/lib/onboard/policy-resume-selection.test.ts b/src/lib/onboard/policy-resume-selection.test.ts index ae7bcf77919..4bfd854520b 100644 --- a/src/lib/onboard/policy-resume-selection.test.ts +++ b/src/lib/onboard/policy-resume-selection.test.ts @@ -10,9 +10,14 @@ type Preset = { name: string; access?: string }; function policies( options: { applied?: string[]; custom?: string[]; customOwnsObservability?: boolean } = {}, ) { - const setupPresets = ["npm", "brave", "tavily", "slack", "observability-otlp-local"].map( - (name) => ({ name }), - ); + const setupPresets = [ + "npm", + "brave", + "tavily", + "slack", + "observability-otlp-local", + "personal-open-internet", + ].map((name) => ({ name })); const customPresets = (options.custom ?? []).map((name) => ({ name })); return { setupPolicyPresetSupported: () => true, @@ -88,6 +93,47 @@ describe("preparePolicyPresetResumeSelection web search reconciliation", () => { }); describe("preparePolicyPresetResumeSelection required preset reconciliation", () => { + it.each(["openclaw", "hermes", "langchain-deepagents-code", "pi"])( + "repairs a Personal recording missing its tier-defining preset: %s", + (agent) => { + const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { + recordedPolicyPresets: ["npm"], + agent, + tierName: "personal", + webSearchConfig: null, + webSearchSupported: true, + }); + + expect(result.policyPresets).toEqual(["personal-open-internet", "npm"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + }, + ); + + it("returns the Personal requirement when the legacy recording is null", () => { + const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { + recordedPolicyPresets: null, + agent: "pi", + tierName: "personal", + webSearchConfig: null, + webSearchSupported: true, + }); + + expect(result.policyPresets).toEqual(["personal-open-internet"]); + }); + + it("marks an explicit empty Personal recording for reconciliation", () => { + const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { + recordedPolicyPresets: [], + agent: "pi", + tierName: "personal", + webSearchConfig: null, + webSearchSupported: true, + }); + + expect(result.policyPresets).toEqual(["personal-open-internet"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + }); + it("marks an empty recording for reconciliation when Slack becomes required (#6042)", () => { const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { recordedPolicyPresets: [], diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts index 229d047b759..890f785b71d 100644 --- a/src/lib/onboard/policy-resume-selection.ts +++ b/src/lib/onboard/policy-resume-selection.ts @@ -19,7 +19,10 @@ import { isStaleBuiltinWebSearchPolicyPreset, mergeRequiredSetupPolicyPresets, } from "./policy-preset-reconciliation"; -import { suppressedAgentRequiredPresets } from "./policy-tier-suppression"; +import { + ensureRequiredTierPolicyPresets, + suppressedAgentRequiredPresets, +} from "./policy-tier-suppression"; type Preset = { name: string; access?: string }; @@ -183,6 +186,7 @@ export function preparePolicyPresetResumeSelection( policyPresets.push(activeWebSearchPreset); } } + policyPresets = ensureRequiredTierPolicyPresets(options.tierName, policyPresets); const recordedPolicyPresetsNeedReconcile = Array.isArray(options.recordedPolicyPresets) && (policyPresets.length !== options.recordedPolicyPresets.length || diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 90d4528f6b3..d14779d0cef 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -4,7 +4,7 @@ import { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search"; import * as policies from "../policy"; import * as tiers from "../policy/tiers"; -import { PERSONAL_POLICY_TIER_NAME } from "../policy/tiers"; +import { PERSONAL_OPEN_INTERNET_PRESET_NAME, PERSONAL_POLICY_TIER_NAME } from "../policy/tiers"; import { filterSetupPolicyPresetNamesForAgent, filterSetupPolicyPresetsForAgent, @@ -43,6 +43,8 @@ import * as policyTierEnv from "./policy-tier-env"; import { agentRequiredPresetAdditions, emitSuppressedAgentRequiredPresetsNote, + ensureRequiredTierPolicyPresets, + filterSuppressedAgentRequiredPresets, RESTRICTED_TIER_NAME, } from "./policy-tier-suppression"; import { withPolicyApplicationTrace } from "./tracing"; @@ -249,14 +251,12 @@ 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, @@ -323,7 +323,7 @@ export function computeSetupPresetSuggestions( if (HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset)) add(preset); } } - return suggestions; + return filterSuppressedAgentRequiredPresets(suggestions, tierName, agent); } export { type PreparedPolicyResumeSelection, preparePolicyPresetResumeSelection }; @@ -357,6 +357,18 @@ function requireSandboxReady( } } +function refuseInPlacePersonalRemoval( + personalAlreadyActive: boolean, + target: readonly string[], +): void { + if (personalAlreadyActive && !target.includes(PERSONAL_OPEN_INTERNET_PRESET_NAME)) { + console.error( + " Personal open internet cannot be removed in place because it replaces overlapping web routes. Create a new sandbox with another policy tier instead.", + ); + process.exit(1); + } +} + async function setupPoliciesWithSelectionInner( deps: SetupPolicySelectionDeps, sandboxName: string, @@ -453,7 +465,12 @@ async function setupPoliciesWithSelectionInner( // still get filtered. An interrupted create can reach this fresh-selection // branch before presets are recorded, so its persisted tier must also win // over a new prompt or non-interactive default. - const recordedTierName = options.tierName ?? deps.getRecordedPolicyTier?.(sandboxName) ?? null; + const persistedTierName = deps.getRecordedPolicyTier?.(sandboxName) ?? null; + const recordedTierName = options.tierName ?? persistedTierName; + const personalAlreadyActive = + currentAppliedPresets.includes(PERSONAL_OPEN_INTERNET_PRESET_NAME) || + persistedTierName === PERSONAL_POLICY_TIER_NAME || + (selectedPresets !== null && options.tierName === PERSONAL_POLICY_TIER_NAME); if (chosen !== null) { const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); chosen = mergeRequiredSetupPolicyPresets(chosen, { @@ -472,10 +489,12 @@ async function setupPoliciesWithSelectionInner( // (e.g. `brave` on Balanced) via provenance — a reconcile-triggered reuse // reapply must not narrow an applied tier default. (#6844) chosen = excludePresets(pruneUnavailablePresets(chosen, { tierName: recordedTierName })); + chosen = ensureRequiredTierPolicyPresets(recordedTierName, chosen); } if (selectedPresets !== null) { const resumeSelection = chosen || []; + refuseInPlacePersonalRemoval(personalAlreadyActive, resumeSelection); if (onSelection) onSelection(resumeSelection); requireSandboxReady(deps, sandboxName, "before"); deps.note(` [resume] Reapplying policy presets: ${resumeSelection.join(", ")}`); @@ -485,6 +504,9 @@ async function setupPoliciesWithSelectionInner( } const tierName = recordedTierName ?? (await deps.selectPolicyTier()); + if (personalAlreadyActive && tierName !== PERSONAL_POLICY_TIER_NAME) { + refuseInPlacePersonalRemoval(personalAlreadyActive, []); + } deps.setPolicyTier?.(sandboxName, tierName); const personalTier = tierName === PERSONAL_POLICY_TIER_NAME; const suggestions = excludePresets( @@ -513,17 +535,32 @@ async function setupPoliciesWithSelectionInner( let isAuthoritative = false; if (policyMode === "skip" || policyMode === "none" || policyMode === "no") { - deps.note(" [non-interactive] Skipping policy presets."); - const retainedPresets = excludePresets(pruneUnavailablePresets(currentAppliedPresets)); - if (retainedPresets.length < currentAppliedPresets.length) { + const retainedPresets = ensureRequiredTierPolicyPresets( + tierName, + filterSuppressedAgentRequiredPresets( + excludePresets(pruneUnavailablePresets(currentAppliedPresets)), + tierName, + agent, + ), + ); + const selectionChanged = + retainedPresets.length !== currentAppliedPresets.length || + retainedPresets.some((name, index) => name !== currentAppliedPresets[index]); + if (selectionChanged) { + refuseInPlacePersonalRemoval(personalAlreadyActive, retainedPresets); if (onSelection) onSelection(retainedPresets); requireSandboxReady(deps, sandboxName, "before"); - deps.note(" [non-interactive] Removing excluded or unavailable policy presets."); + deps.note( + personalTier + ? " [non-interactive] Applying the Personal tier requirement while skipping optional policy presets." + : " [non-interactive] Removing excluded or unavailable policy presets.", + ); deps.syncPresetSelection(sandboxName, currentAppliedPresets, retainedPresets); requireSandboxReady(deps, sandboxName, "after"); return retainedPresets; } - return []; + deps.note(" [non-interactive] Skipping optional policy presets."); + return personalTier ? retainedPresets : []; } if (policyMode === "custom" || policyMode === "list") { @@ -569,6 +606,7 @@ async function setupPoliciesWithSelectionInner( preserveExplicitWebSearch: isAuthoritative || personalTier, }), ); + chosen = ensureRequiredTierPolicyPresets(tierName, chosen); const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); if (invalidPresets.length > 0) { @@ -596,6 +634,7 @@ async function setupPoliciesWithSelectionInner( } } + refuseInPlacePersonalRemoval(personalAlreadyActive, chosen); if (onSelection) onSelection(chosen); requireSandboxReady(deps, sandboxName, "before"); deps.note(` [non-interactive] Applying policy presets: ${chosen.join(", ")}`); @@ -614,27 +653,31 @@ async function setupPoliciesWithSelectionInner( allPresets, initialSelected, ); - const interactiveChoice = excludePresets( - pruneUnavailablePresets( - mergeRequiredSetupPolicyPresets( - resolvedPresets.map((preset) => preset.name), - { - enabledChannels, - hermesToolGateways, - agent, - observabilityEnabled, - knownPresetNames: knownNames, - env: deps.env, - tierName, - webSearchConfig, - customPresetNames, - customOwnsObservability, - }, + const interactiveChoice = ensureRequiredTierPolicyPresets( + tierName, + excludePresets( + pruneUnavailablePresets( + mergeRequiredSetupPolicyPresets( + resolvedPresets.map((preset) => preset.name), + { + enabledChannels, + hermesToolGateways, + agent, + observabilityEnabled, + knownPresetNames: knownNames, + env: deps.env, + tierName, + webSearchConfig, + customPresetNames, + customOwnsObservability, + }, + ), + { preserveExplicitWebSearch: true }, ), - { preserveExplicitWebSearch: true }, ), ); + refuseInPlacePersonalRemoval(personalAlreadyActive, interactiveChoice); if (onSelection) onSelection(interactiveChoice); requireSandboxReady(deps, sandboxName, "before"); diff --git a/src/lib/onboard/policy-tier-suppression.ts b/src/lib/onboard/policy-tier-suppression.ts index 380a76eece5..276fdc14b65 100644 --- a/src/lib/onboard/policy-tier-suppression.ts +++ b/src/lib/onboard/policy-tier-suppression.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { PERSONAL_OPEN_INTERNET_PRESET_NAME, PERSONAL_POLICY_TIER_NAME } from "../policy/tiers"; import { isDcodeAgent, OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, @@ -18,6 +19,22 @@ export function normalizePolicyTierName(tierName: string | null | undefined): st return tierName.trim().toLowerCase() || null; } +/** Keep tier-defining presets present independently of agent and selection mode. */ +export function ensureRequiredTierPolicyPresets( + tierName: string | null | undefined, + presetNames: readonly string[], +): string[] { + if (normalizePolicyTierName(tierName) !== PERSONAL_POLICY_TIER_NAME) { + return [...presetNames]; + } + return [ + PERSONAL_OPEN_INTERNET_PRESET_NAME, + ...presetNames.filter( + (name) => name.trim().toLowerCase() !== PERSONAL_OPEN_INTERNET_PRESET_NAME, + ), + ]; +} + export function agentRequiredPresetAdditions( agent: string | null | undefined, env: NodeJS.ProcessEnv, @@ -34,6 +51,10 @@ function restrictedIncompatibleAgentRequiredPresets(agent: string | null | undef return []; } +function personalSupersededAgentRequiredPresets(agent: string | null | undefined): string[] { + return isOpenclawAgent(agent) ? ["openclaw-pricing"] : []; +} + /** * Invalid state: OpenClaw onboarding adds `openclaw-pricing` (and, when * `NEMOCLAW_OPENCLAW_OTEL=1` with a local endpoint, `openclaw-diagnostics-otel-local`) @@ -94,8 +115,14 @@ export function suppressedAgentRequiredPresets( tierName: string, agent: string | null | undefined, ): string[] { - if (normalizePolicyTierName(tierName) !== RESTRICTED_TIER_NAME) return []; - return restrictedIncompatibleAgentRequiredPresets(agent); + const normalizedTier = normalizePolicyTierName(tierName); + if (normalizedTier === RESTRICTED_TIER_NAME) { + return restrictedIncompatibleAgentRequiredPresets(agent); + } + if (normalizedTier === PERSONAL_POLICY_TIER_NAME) { + return personalSupersededAgentRequiredPresets(agent); + } + return []; } export function filterSuppressedAgentRequiredPresets( @@ -116,9 +143,16 @@ export function emitSuppressedAgentRequiredPresetsNote( ): Set { const suppressed = suppressedAgentRequiredPresets(tierName, agent); if (suppressed.length > 0) { - note( - ` Restricted tier suppresses agent-required preset(s): ${suppressed.join(", ")}. Apply later with 'nemoclaw policy add ' if needed.`, - ); + const normalizedTier = normalizePolicyTierName(tierName); + if (normalizedTier === PERSONAL_POLICY_TIER_NAME) { + note( + ` Personal tier supersedes exact web preset(s): ${suppressed.join(", ")} (${PERSONAL_OPEN_INTERNET_PRESET_NAME} already owns ports 80 and 443).`, + ); + } else { + note( + ` Restricted tier suppresses agent-required preset(s): ${suppressed.join(", ")}. Apply later with 'nemoclaw policy add ' if needed.`, + ); + } } return new Set(suppressed); } diff --git a/src/lib/onboard/portable-environment-scope.test.ts b/src/lib/onboard/portable-environment-scope.test.ts index 570a0700f62..7dfcfcaa4c9 100644 --- a/src/lib/onboard/portable-environment-scope.test.ts +++ b/src/lib/onboard/portable-environment-scope.test.ts @@ -51,37 +51,42 @@ describe("portable onboarding environment scope", () => { expect(env).toEqual({ NEMOCLAW_MODEL: "" }); }); - it("uses the narrow portable policy defaults when fresh intent is absent (#9200)", () => { + it("uses the Personal tier suggestions when fresh policy intent is absent (#9206)", () => { const env: NodeJS.ProcessEnv = {}; const scope = createPortableOnboardEnvironmentScope(env, null); - expect(env.NEMOCLAW_POLICY_PRESETS).toBe("weather,public-reference,github"); - expect(env.NEMOCLAW_POLICY_PRESETS).not.toContain("personal-open-internet"); + expect(env.NEMOCLAW_POLICY_TIER).toBe("personal"); + expect(env.NEMOCLAW_POLICY_MODE).toBe("suggested"); + expect(env.NEMOCLAW_POLICY_PRESETS).toBeUndefined(); scope.restore(); expect(env).toEqual({}); }); it.each(["", " \t "])( - "uses the narrow portable policy defaults and restores blank fresh intent %# (#9200)", + "uses the Personal tier suggestions and restores blank fresh intent %# (#9206)", (policyPresets) => { const env: NodeJS.ProcessEnv = { NEMOCLAW_POLICY_PRESETS: policyPresets }; const scope = createPortableOnboardEnvironmentScope(env, null); - expect(env.NEMOCLAW_POLICY_PRESETS).toBe("weather,public-reference,github"); + expect(env.NEMOCLAW_POLICY_TIER).toBe("personal"); + expect(env.NEMOCLAW_POLICY_MODE).toBe("suggested"); + expect(env.NEMOCLAW_POLICY_PRESETS).toBeUndefined(); scope.restore(); expect(env).toEqual({ NEMOCLAW_POLICY_PRESETS: policyPresets }); }, ); - it("preserves a nonblank fresh portable policy list (#9200)", () => { + it("adds required Personal access to explicit Portable presets (#9206)", () => { const env: NodeJS.ProcessEnv = { NEMOCLAW_POLICY_PRESETS: "github,weather", }; const scope = createPortableOnboardEnvironmentScope(env, null); - expect(env.NEMOCLAW_POLICY_PRESETS).toBe("github,weather"); + expect(env.NEMOCLAW_POLICY_TIER).toBe("personal"); + expect(env.NEMOCLAW_POLICY_MODE).toBe("custom"); + expect(env.NEMOCLAW_POLICY_PRESETS).toBe("personal-open-internet,github,weather"); scope.restore(); expect(env).toEqual({ NEMOCLAW_POLICY_PRESETS: "github,weather" }); diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index 8aba28f4ea6..56ae9be5e7a 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { ServingProfileProvenance } from "../inference/serving/types"; +import { PERSONAL_POLICY_TIER_NAME } from "../policy/tiers"; import { redactSensitiveText } from "../security/redact"; import { isDecisionSelected } from "../state/onboard-checkpoint-decision"; import { @@ -27,6 +28,7 @@ import type { PortableInferenceActivation } from "./experimental/portable-infere import { requireReadOnlyHostMountRuntimeSupport } from "./host-mount"; import type { ResumeConfigConflict } from "./resume-config"; import type { StationExpressResumeIntent } from "./station-express-resume"; +import { ensureRequiredTierPolicyPresets } from "./policy-tier-suppression"; import { assertLockedResumeIntentSnapshot as assertLockedResumeIntentSnapshotAtPath, isOnboardResumeIntentRaceError, @@ -107,8 +109,6 @@ const PORTABLE_OWNED_ENV_KEYS = [ ...PORTABLE_DEFAULT_ENV_KEYS, ] as const; -const PORTABLE_DEFAULT_POLICY_PRESETS = "weather,public-reference,github"; - interface PreviousEnvironmentValue { readonly present: boolean; readonly value: string | undefined; @@ -226,11 +226,19 @@ export function createPortableOnboardEnvironmentScope( env[TOOL_DISCLOSURE_ENV] = "direct"; env.NEMOCLAW_PROVIDER = activation ? "custom" : "ollama"; env.NEMOCLAW_MODEL = activation?.model ?? (requestedModel || "qwen3-vl:4b"); - env.NEMOCLAW_POLICY_MODE = "custom"; - env.NEMOCLAW_POLICY_PRESETS = requestedPolicyPresets?.trim() - ? requestedPolicyPresets - : PORTABLE_DEFAULT_POLICY_PRESETS; - env.NEMOCLAW_POLICY_TIER = "personal"; + env.NEMOCLAW_POLICY_TIER = PERSONAL_POLICY_TIER_NAME; + if (requestedPolicyPresets?.trim()) { + env.NEMOCLAW_POLICY_MODE = "custom"; + env.NEMOCLAW_POLICY_PRESETS = ensureRequiredTierPolicyPresets( + PERSONAL_POLICY_TIER_NAME, + requestedPolicyPresets + .split(",") + .map((name) => name.trim()) + .filter(Boolean), + ).join(","); + } else { + env.NEMOCLAW_POLICY_MODE = "suggested"; + } } else { const requestedPolicyPresets = previous.get("NEMOCLAW_POLICY_PRESETS")?.value?.trim(); if (requestedPolicyPresets) { diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index fb29dca58e8..4055f6f57ed 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -49,7 +49,10 @@ import { stripProviderComposedPolicies, withoutProviderComposedPolicies, } from "./merge"; -import { findUnexpectedExistingPolicyKey } from "./preset-ownership"; +import { + findUnexpectedExistingPolicyKey, + PERSONAL_OPEN_INTERNET_PRESET_NAME, +} from "./preset-ownership"; import { isPolicyDocument, isPolicyObject, @@ -72,6 +75,9 @@ import { const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); +const PERSONAL_OPEN_INTERNET_POLICY_KEY = "personal_open_internet"; +const PERSONAL_OPEN_INTERNET_PORTS = new Set([80, 443]); + const MAX_PRESET_FILE_BYTES = 10_000_000; type PresetInfo = { @@ -622,7 +628,95 @@ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): st } output.network_policies = mergedNp; - return YAML.stringify(output); + return normalizePersonalOpenInternetPolicy(YAML.stringify(output)); +} + +/** + * OpenShell 0.0.101 rejects a hostless `allowed_ips` endpoint when any other + * endpoint selects the same port with different connection metadata. Personal + * deliberately grants every sandbox binary direct L4 access on ports 80/443, + * so exact web endpoints add no transport authority while Personal is active. + * Keep the reviewed Personal entry as the sole web authority and retain every + * non-web endpoint and non-network policy section unchanged. OpenShell handles + * `inference.local` before ordinary network-policy evaluation, so removing its + * overlapping base-policy endpoint does not remove routed inference. + */ +function normalizePersonalOpenInternetPolicy(policyContent: string): string { + let document: PolicyDocument; + try { + const parsed = YAML.parse(policyContent); + if (!isPolicyDocument(parsed)) return policyContent; + document = parsed; + } catch { + return policyContent; + } + + const networkPolicies = document.network_policies; + if (!isPolicyObject(networkPolicies)) return policyContent; + if (!Object.prototype.hasOwnProperty.call(networkPolicies, PERSONAL_OPEN_INTERNET_POLICY_KEY)) { + return policyContent; + } + const personalEntry = networkPolicies[PERSONAL_OPEN_INTERNET_POLICY_KEY]; + + const reviewedContent = loadCentralPreset(PERSONAL_OPEN_INTERNET_PRESET_NAME, { + reportMissing: false, + }); + const reviewedEntry = parseNetworkPolicies(reviewedContent)?.[PERSONAL_OPEN_INTERNET_POLICY_KEY]; + if ( + !isPolicyObject(personalEntry) || + !isPolicyObject(reviewedEntry) || + !isDeepStrictEqual(personalEntry, reviewedEntry) + ) { + throw new Error( + `Cannot compose Personal policy: reserved network policy key '${PERSONAL_OPEN_INTERNET_POLICY_KEY}' does not match the reviewed built-in preset.`, + ); + } + + const normalizedPolicies: PolicyObject = {}; + for (const [policyKey, policyValue] of Object.entries(networkPolicies)) { + if (policyKey === PERSONAL_OPEN_INTERNET_POLICY_KEY || !isPolicyObject(policyValue)) { + normalizedPolicies[policyKey] = policyValue; + continue; + } + + if (!Array.isArray(policyValue.endpoints)) { + normalizedPolicies[policyKey] = policyValue; + continue; + } + + const endpoints: PolicyValue[] = []; + for (const endpointValue of policyValue.endpoints) { + if (!isPolicyObject(endpointValue)) { + endpoints.push(endpointValue); + continue; + } + + const port = endpointValue.port; + if (typeof port === "number" && PERSONAL_OPEN_INTERNET_PORTS.has(port)) continue; + + const ports = endpointValue.ports; + if (!Array.isArray(ports)) { + endpoints.push(endpointValue); + continue; + } + const retainedPorts = ports.filter( + (candidate) => + typeof candidate !== "number" || !PERSONAL_OPEN_INTERNET_PORTS.has(candidate), + ); + if (retainedPorts.length === 0) continue; + endpoints.push( + retainedPorts.length === ports.length + ? endpointValue + : { ...endpointValue, ports: retainedPorts }, + ); + } + + if (endpoints.length > 0) { + normalizedPolicies[policyKey] = { ...policyValue, endpoints }; + } + } + + return YAML.stringify({ ...document, network_policies: normalizedPolicies }); } export type PresetPolicyState = "absent" | "drift" | "match"; @@ -958,7 +1052,8 @@ function mergePresetNamesIntoPolicy( let policy = merged; if ( (options.agent === undefined || options.agent === null || options.agent === "openclaw") && - appliedPresets.includes("npm") + appliedPresets.includes("npm") && + !policyHasNetworkPolicy(merged, PERSONAL_OPEN_INTERNET_POLICY_KEY) ) { const reviewedBaseline = resolveAgentBaselinePolicy("openclaw"); if (!reviewedBaseline) { @@ -972,7 +1067,11 @@ function mergePresetNamesIntoPolicy( policyHasNetworkPolicy(currentPolicy, OPENCLAW_NPM_PRESET_KEY), ).policy; } - return { policy, appliedPresets, missingPresets }; + return { + policy: normalizePersonalOpenInternetPolicy(policy), + appliedPresets, + missingPresets, + }; } /** @@ -1075,6 +1174,13 @@ function removePreset( ); } + if (presetName === PERSONAL_OPEN_INTERNET_PRESET_NAME) { + console.error( + " Personal open internet cannot be removed in place because it replaces overlapping web routes. Create a new sandbox with another policy tier instead.", + ); + return false; + } + // Resolve preset content: built-in first, then custom presets persisted // in the registry. `isCustom` controls which registry bucket to prune on // success. @@ -1115,14 +1221,13 @@ function removePreset( return false; } - let updated = removePresetFromPolicy(currentPolicy, presetEntries); + let openClawNpmBaseline: string | null = null; if (!isCustom && presetName === "npm") { try { - const baseline = resolveSandboxOpenClawNpmBaseline(sandboxName); - if (baseline) { + openClawNpmBaseline = resolveSandboxOpenClawNpmBaseline(sandboxName); + if (openClawNpmBaseline) { const exclusionError = openClawNpmExclusionStateError(sandboxName, currentPolicy); if (exclusionError) throw new Error(exclusionError); - updated = restoreOpenClawNpmCompatibility(currentPolicy, updated, baseline); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1131,6 +1236,50 @@ function removePreset( } } + const supersededByPersonal = + policyHasNetworkPolicy(currentPolicy, PERSONAL_OPEN_INTERNET_POLICY_KEY) && + classifyPresetEntries(currentPolicy, presetEntries) === "absent" && + policyDocumentsMatch(currentPolicy, mergePresetIntoPolicy(currentPolicy, presetEntries)); + if (supersededByPersonal) { + const sandbox = options.skipRegistryUpdate ? undefined : registry.getSandbox(sandboxName); + const attributionRecorded = + options.skipRegistryUpdate === true || + (isCustom + ? (sandbox?.customPolicies ?? []).some((policy) => policy.name === presetName) + : (sandbox?.policies ?? []).includes(presetName)); + if (!attributionRecorded) { + console.error(` Preset '${presetName}' could not be removed from the current policy.`); + return false; + } + if (sandbox) { + const attributionRemoved = isCustom + ? registry.removeCustomPolicyByName(sandboxName, presetName) + : registry.updateSandbox(sandboxName, { + policies: (sandbox.policies ?? []).filter((name) => name !== presetName), + }); + if (!attributionRemoved) { + console.error(` Preset '${presetName}' could not be removed from the registry.`); + return false; + } + } + console.log( + ` Removed preset: ${presetName} (Personal remains the sole web authority; live policy unchanged).`, + ); + return true; + } + + let updated = removePresetFromPolicy(currentPolicy, presetEntries); + if (openClawNpmBaseline) { + try { + updated = restoreOpenClawNpmCompatibility(currentPolicy, updated, openClawNpmBaseline); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(` Refusing to remove npm policy compatibility: ${message}`); + return false; + } + } + updated = normalizePersonalOpenInternetPolicy(updated); + if (updated === currentPolicy) { console.error(` Preset '${presetName}' could not be removed from the current policy.`); return false; @@ -1924,10 +2073,11 @@ function applyPresetContent( if (options.custom) { const np = parseNetworkPolicies(presetContent); - if (np && Object.prototype.hasOwnProperty.call(np, OPENCLAW_NPM_PRESET_KEY)) { - console.error( - ` Custom presets cannot own reserved network policy key '${OPENCLAW_NPM_PRESET_KEY}'.`, - ); + const reservedKey = [OPENCLAW_NPM_PRESET_KEY, PERSONAL_OPEN_INTERNET_POLICY_KEY].find( + (key) => np && Object.prototype.hasOwnProperty.call(np, key), + ); + if (reservedKey) { + console.error(` Custom presets cannot own reserved network policy key '${reservedKey}'.`); return false; } const hasGeneratedPins = np !== null && networkPoliciesHasAllowedIps(np); @@ -2017,9 +2167,21 @@ function applyPresetContent( return false; } } - let merged = mergePresetIntoPolicy(currentPolicy, presetEntries); + let merged: string; + try { + merged = mergePresetIntoPolicy(currentPolicy, presetEntries); + } catch (error) { + if (!options.nonFatal) throw error; + const message = error instanceof Error ? error.message : String(error); + console.error(` Refusing to apply preset '${presetName}': ${message}`); + return false; + } let npmBaselineWidened = false; - if (!options.custom && presetName === "npm") { + if ( + !options.custom && + presetName === "npm" && + !policyHasNetworkPolicy(merged, PERSONAL_OPEN_INTERNET_POLICY_KEY) + ) { try { const baseline = resolveSandboxOpenClawNpmBaseline(sandboxName); if (baseline) { @@ -2039,6 +2201,14 @@ function applyPresetContent( return false; } } + try { + merged = normalizePersonalOpenInternetPolicy(merged); + } catch (error) { + if (!options.nonFatal) throw error; + const message = error instanceof Error ? error.message : String(error); + console.error(` Refusing to apply preset '${presetName}': ${message}`); + return false; + } const presetState = classifyPresetEntries(currentPolicy, presetEntries); const disclosedPresetState = @@ -2232,7 +2402,10 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { } let npmBaselineWidened = false; - if (uniquePresetNames.includes("npm")) { + if ( + uniquePresetNames.includes("npm") && + !policyHasNetworkPolicy(merged, PERSONAL_OPEN_INTERNET_POLICY_KEY) + ) { try { const baseline = resolveSandboxOpenClawNpmBaseline(sandboxName); if (baseline) { @@ -2252,6 +2425,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { return false; } } + merged = normalizePersonalOpenInternetPolicy(merged); for (const preset of presetContents) { const disclosedPresetState = diff --git a/src/lib/policy/preset-ownership.ts b/src/lib/policy/preset-ownership.ts index 1dea5169dd7..cd4c8af1906 100644 --- a/src/lib/policy/preset-ownership.ts +++ b/src/lib/policy/preset-ownership.ts @@ -5,6 +5,8 @@ import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; +export { PERSONAL_OPEN_INTERNET_PRESET_NAME } from "./tiers"; + function policyMap(content: string): Record { const policies = YAML.parse(content)?.network_policies; return policies && typeof policies === "object" && !Array.isArray(policies) ? policies : {}; diff --git a/src/lib/policy/tiers.ts b/src/lib/policy/tiers.ts index 6ae5258a755..18caf6a871c 100644 --- a/src/lib/policy/tiers.ts +++ b/src/lib/policy/tiers.ts @@ -19,6 +19,7 @@ import { ROOT } from "../runner"; const TIERS_FILE = path.join(ROOT, "nemoclaw-blueprint", "policies", "tiers.yaml"); export const PERSONAL_POLICY_TIER_NAME = "personal"; +export const PERSONAL_OPEN_INTERNET_PRESET_NAME = "personal-open-internet"; const ALLOWED_ACCESS: ReadonlySet = new Set(["read", "read-write"]); type TierAccess = "read" | "read-write"; diff --git a/test/e2e/README.md b/test/e2e/README.md index ed0f54e6116..2316eba4995 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -264,6 +264,13 @@ The execution profile owns the credentials available to its target step: The reusable workflow enforces this boundary; PR revision callers set `trusted_main` to `false`, so their target steps receive no `GITHUB_TOKEN`. - `brave-nvidia-inference` displays `Brave and NVIDIA inference API keys` and receives `BRAVE_API_KEY` and `NVIDIA_INFERENCE_API_KEY` on trusted `main` runs. +`common-egress-agent` runs 4 isolated scenario shards. +The Personal stock-price shard exercises ordinary onboarding with an explicit Personal selection; it does not exercise Portable profile selection. +It uses OpenClaw as one representative agent witness, runs with `nvidia-inference`, sets web search to `none`, and receives no Brave Search or Tavily Search API key. +The Personal stock assertion disables the ordinary agent-attempt shell artifact because OpenClaw stdout can contain the complete source URL. +Raw OpenClaw session and trajectory JSONL stay inside the sandbox; uploaded evidence contains only the price and source date, the source hostname and protocol, and bounded reduced evidence such as tool names, public target hosts, provider labels, final statuses, and quote-match booleans. +The live assertions require `web_fetch`, reject `web_search` and search-provider use, permit public access from curl and Python, and deny loopback and link-local targets. + GitHub Actions renders each catalogue execution as ` / `. All catalogue profiles call `.github/workflows/e2e-standard-profile.yaml`. Each target selects its runner through the catalogue. @@ -810,8 +817,9 @@ concrete job executions. - `hermes-shields-config` - `security-posture` with the `hermes` shard -The two extra executions come from `common-egress-agent`, which runs three -scenario shards. +The two extra instrumented executions come from the 3 `common-egress-agent` +scenario shards that enable runner comparison. +The Personal stock-price shard runs without runner-comparison telemetry. The OpenClaw matrix entries for `mcp-bridge`, `channels-stop-start`, and `security-posture` are not instrumented. The #7145 standard-versus-larger-runner cohort compares the same lane and diff --git a/test/e2e/RETRY_INVENTORY.md b/test/e2e/RETRY_INVENTORY.md index 64c05e7c1b8..0559fd05486 100644 --- a/test/e2e/RETRY_INVENTORY.md +++ b/test/e2e/RETRY_INVENTORY.md @@ -27,7 +27,7 @@ Exhaustion remains failed. | `common-egress-provider-validation-skip` | Common-egress provider validation before sandbox assertions; `test/e2e/live/common-egress-agent-helpers.ts`, `test/e2e/live/common-egress-agent.test.ts` | Shared transient provider allowlist, explicit HTTP 429/rate-limit evidence, or CI-only sanitized endpoint-validation evidence; terminal authentication, authorization, policy, malformed-input, routing, and proxy signals take precedence | No retry; at most one local or CI skip | Read-only provider validation | Inference provider | Exact transient, HTTP 429, or sanitized classification versus terminal failure | Skip reason, classifier booleans, bounded output tails, and command artifacts | Intentional pre-contract skip; release conclusion unchanged | | `hosted-inference-budget` | Hosted Hermes inference checks; `test/e2e/live/hermes-e2e.test.ts` | Direct provider and sandbox `inference.local` responses must finish without exhausting their reasoning budget | No retry; 2 fixed requests with 1,024 output tokens each | Read-only inference request | Test owner | Deterministic response assertion | Direct and sandbox chat artifacts | Ineligible deterministic retry removed | | `cloud-inference-probe` | Hosted cloud inference request; `test/e2e/live/cloud-inference.test.ts` | No-response timeout flag or output containing `timed out`, `timeout`, `ETIMEDOUT`, `ECONNRESET`, `EAI_AGAIN`, `ENOTFOUND`, `failed to connect`, `rate-limit`, or `rate limit`; HTTP 408/429 or HTTP 5xx | 1-10 attempts (default 3); configurable fixed 1-10s delay (default 5s) | Read-only inference request | Inference provider | Explicit external transient versus terminal response/semantic assertion | Per-request artifacts and aggregate attempt evidence | Eligible bounded read; HTTP status is separated from provider content and deterministic assertions do not retry | -| `agent-turn-probe` | OpenClaw and Hermes agent turns; `test/e2e/live/common-egress-agent.test.ts`, `test/e2e/live/common-egress-agent-helpers.ts` | Exact OpenClaw scope-upgrade signal; a retry requires successful recovery first. Explicit Hermes HTTP 408/429/5xx, or Hermes transport/provider timeout, reset, DNS, and connection evidence only when no response status exists; terminal authentication, authorization, policy, malformed-input, and successful-command product mismatches take precedence | 3 attempts; OpenClaw linear 15s, Hermes linear 5s | Hermes inference requests are read-only. OpenClaw prompts can write agent session or proof state, so plain ambiguous transport failures do not retry; only the exact pre-prompt scope-upgrade failure may retry after successful `nemoclaw recover` reconciliation. | Agent runtime or inference provider | Explicit transient convergence versus terminal product response | Per-attempt redacted command and recovery artifacts plus progress; credential-free aggregate `RetryEvidence` outcomes in `retry/