diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index 2b41cf7b82c..54162a8c002 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -193,7 +193,7 @@ { "name": "Declarative network policy", "status": "tested", - "notes": "YAML-defined egress with policy presets. Presets include `slack`, `discord`, `telegram`, `weather`, `openclaw-pricing`, `huggingface`, `npm`, `pypi`, `brew`, and others. Hot-reloadable at runtime with `$$nemoclaw policy-add`." + "notes": "YAML-defined egress with policy presets. Presets include `slack`, `discord`, `telegram`, `weather`, `openclaw-pricing`, `huggingface`, `npm`, `pypi`, `brew`, and others. Hot-reloadable at runtime with `$$nemoclaw policy add`." }, { "name": "Snapshot and restore", diff --git a/docs/deployment/deploy-to-headless-server.mdx b/docs/deployment/deploy-to-headless-server.mdx index 79c7ef69370..2d31351d367 100644 --- a/docs/deployment/deploy-to-headless-server.mdx +++ b/docs/deployment/deploy-to-headless-server.mdx @@ -348,9 +348,9 @@ Scope the host, port, method, path, and executable to the smallest required set. Save a reviewed preset as `./presets/internal-status.yaml`, preview it, then apply it without a prompt: ```bash -$$nemoclaw headless-agent policy-add --from-file ./presets/internal-status.yaml --dry-run -$$nemoclaw headless-agent policy-add --from-file ./presets/internal-status.yaml --yes -$$nemoclaw headless-agent policy-list +$$nemoclaw headless-agent policy add --from-file ./presets/internal-status.yaml --dry-run +$$nemoclaw headless-agent policy add --from-file ./presets/internal-status.yaml --yes +$$nemoclaw headless-agent policy list ``` `--yes` skips the confirmation prompt but does not skip schema, destination, or SSRF validation. @@ -383,7 +383,7 @@ Use `$$nemoclaw headless-agent rebuild` when you need the current agent image wh | Item | Same-container restart | Snapshot and restore | Rebuild or sandbox upgrade | |---|---|---|---| | Provider configuration | Preserved | Provider names are recorded, but raw credentials are not in the snapshot | Regenerated from registry and OpenShell provider state | -| Custom preset YAML applied with `policy-add` | Preserved in registry | Stored content is included in snapshot metadata | Replayed from stored content | +| Custom preset YAML applied with `policy add` | Preserved in registry | Stored content is included in snapshot metadata | Replayed from stored content | | Manifest-defined user and agent state | Preserved | Preserved | Preserved when backup and restore succeed | | Arbitrary files outside manifest state | Usually remain in the same writable layer | Not preserved | Not preserved | | Manually installed system or global packages | Usually remain in the same writable layer | Not preserved | Not preserved | diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 6f902cd9626..79b9a120d38 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -288,9 +288,9 @@ The policy preset is a per-sandbox managed-Python opt-in, but provider registrat ```bash # Preview the endpoints the preset opens: -nemo-deepagents policy-add tavily --dry-run +nemo-deepagents policy add tavily --dry-run # Apply it: -nemo-deepagents policy-add tavily --yes +nemo-deepagents policy add tavily --yes # Export the key on the host (NemoClaw reads it from the current shell): export TAVILY_API_KEY=tvly-... # Register the provider with the gateway: @@ -310,7 +310,7 @@ Because OpenShell attributes the harness's calls to the sandbox `python3` interp Remove the target sandbox's managed-Python opt-in when it is no longer needed. ```bash -nemo-deepagents policy-remove tavily --yes +nemo-deepagents policy remove tavily --yes ``` This does not unregister the gateway-wide `tavily-search` provider; its credential and Node/curl routes remain available to sandboxes that attach it. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 050ad0b0ea4..039ae538bab 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -358,7 +358,7 @@ Use these details when your first-run path needs more control. Status: nemoclaw my-gpt-claw status Logs: nemoclaw my-gpt-claw logs --follow Model: nemoclaw inference set --model --provider --sandbox my-gpt-claw - Policies: nemoclaw my-gpt-claw policy-add + Policies: nemoclaw my-gpt-claw policy add Credentials: nemoclaw credentials reset && nemoclaw onboard ────────────────────────────────────────────────── ``` diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index 27461cf99ea..90d08a29a96 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -55,8 +55,8 @@ NemoClaw refuses to create a snapshot when it detects an active `dcode` task or Wait for active `dcode` work to finish before running `$$nemoclaw snapshot create`. -Snapshots preserve sandbox registry metadata that affects rebuild behavior, including custom policy presets applied with `policy-add --from-file` or `policy-add --from-dir`. -When you restore a snapshot, NemoClaw replays those recorded custom presets with their stored YAML content, so you do not need the original preset files on disk. +Snapshots preserve sandbox registry metadata that affects rebuild behavior, including custom policy presets applied with `policy add --from-file` or `policy add --from-dir` and baseline network policy entries excluded with `policy exclude`. +When you restore a snapshot, NemoClaw replays those recorded custom presets with their stored YAML content, so you do not need the original preset files on disk, and rebuild continues to apply the recorded baseline exclusions. The target sandbox's current agent manifest remains authoritative for state-file restore behavior. NemoClaw rejects the restore when the snapshot's agent, config directory, state-file path, or state-file strategy conflicts with that manifest. diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index bc6a0f81f3c..b6388f51a0a 100644 --- a/docs/manage-sandboxes/runtime-controls.mdx +++ b/docs/manage-sandboxes/runtime-controls.mdx @@ -23,7 +23,7 @@ NemoClaw applies its security posture in three layers: what onboarding writes in | Inference provider | Runtime route and config update while shields are down; rebuild only if you need to recreate the image | Run `$$nemoclaw shields down`, then `$$nemoclaw inference set`, then restore shields | | Inference model on the current provider | Runtime route and config update while shields are down | Run `$$nemoclaw shields down`, then `$$nemoclaw inference set`, then restore shields | | Sub-agent | Re-onboard required because the sub-agent and workspace are baked at onboard | `$$nemoclaw onboard --recreate-sandbox` | -| Network policy preset | Runtime on the next request; rebuild only if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` or `policy-remove ` | +| Network policy preset | Runtime on the next request; rebuild only if the preset adds bind-mounted secrets | `$$nemoclaw policy add ` or `policy remove ` | | Network allowlist | Runtime on the next request | `openshell policy set` or the interactive approval prompt at the gateway | | Channel tokens | Rebuild required because the channel configuration and credential attachment are created during onboarding or rebuild | `$$nemoclaw channels add `, then accept the rebuild prompt | | Channel enable or disable | Rebuild required because `openclaw.json` is the runtime source of truth | `$$nemoclaw channels stop `, then rebuild | @@ -68,7 +68,7 @@ If preflight detects an unsafe path, invalid config, invalid ownership posture, | Inference provider | Runtime route changes apply immediately; rebuild if you need to rebake model metadata into the image | `$$nemoclaw inference set` for route changes, or `$$nemoclaw rebuild` after changing build-time settings | | Inference model on the current provider | Hot-reloadable through the Hermes config sync path | `$$nemoclaw inference set` | | Agent runtime | Re-onboard required because the agent and state layout are baked at onboard | `$$nemoclaw onboard --recreate-sandbox` or `nemoclaw onboard --agent openclaw --recreate-sandbox` | -| Network policy preset | Runtime on the next request; rebuild only if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` or `policy-remove ` | +| Network policy preset | Runtime on the next request; rebuild only if the preset adds bind-mounted secrets | `$$nemoclaw policy add ` or `policy remove ` | | Network allowlist | Runtime on the next request | `openshell policy set` or the interactive approval prompt at the gateway | | Channel tokens | Rebuild required because the channel configuration and credential attachment are created during onboarding or rebuild | `$$nemoclaw channels add `, then accept the rebuild prompt | | Channel enable or disable | Rebuild required because `/sandbox/.hermes/.env` and Hermes config are baked at image build time | `$$nemoclaw channels stop `, then rebuild | diff --git a/docs/manage-sandboxes/set-up-slack.mdx b/docs/manage-sandboxes/set-up-slack.mdx index 03e7ce249bd..1b7956fd51d 100644 --- a/docs/manage-sandboxes/set-up-slack.mdx +++ b/docs/manage-sandboxes/set-up-slack.mdx @@ -20,7 +20,7 @@ NemoClaw validates both tokens before it saves Slack credentials or enables the This validation calls the live Slack APIs `auth.test` and `apps.connections.open`, so the tokens must belong to a real Slack app. If Slack rejects the tokens, NemoClaw skips the Slack channel and does not apply the `slack` network policy preset. -When Slack is skipped, the preset does not appear as applied in `$$nemoclaw policy-list`. +When Slack is skipped, the preset does not appear as applied in `$$nemoclaw policy list`. To exercise channel setup with placeholder tokens in a restricted network or hermetic test environment, set `NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION=1`. Slack token format checks still apply. diff --git a/docs/monitoring/manage-deepagents-trace-export.mdx b/docs/monitoring/manage-deepagents-trace-export.mdx index 12d8f9be1c2..30e68aff472 100644 --- a/docs/monitoring/manage-deepagents-trace-export.mdx +++ b/docs/monitoring/manage-deepagents-trace-export.mdx @@ -36,7 +36,7 @@ While the collector is stopped, trace delivery fails open and Deep Agents Code c Remove the policy preset to revoke collector reachability immediately: ```bash -nemo-deepagents "$SANDBOX_NAME" policy-remove observability-otlp-local --yes +nemo-deepagents "$SANDBOX_NAME" policy remove observability-otlp-local --yes ``` Removing the preset does not clear the recorded observability choice. diff --git a/docs/monitoring/set-up-deepagents-trace-export.mdx b/docs/monitoring/set-up-deepagents-trace-export.mdx index 230c21827db..81330f487f6 100644 --- a/docs/monitoring/set-up-deepagents-trace-export.mdx +++ b/docs/monitoring/set-up-deepagents-trace-export.mdx @@ -54,14 +54,14 @@ On an existing sandbox, skip mode leaves the live policy unchanged. Inspect the effective policy: ```bash -nemo-deepagents "$SANDBOX_NAME" policy-list +nemo-deepagents "$SANDBOX_NAME" policy list ``` If `observability-otlp-local` is not active, preview and apply it: ```bash -nemo-deepagents "$SANDBOX_NAME" policy-add observability-otlp-local --dry-run -nemo-deepagents "$SANDBOX_NAME" policy-add observability-otlp-local --yes +nemo-deepagents "$SANDBOX_NAME" policy add observability-otlp-local --dry-run +nemo-deepagents "$SANDBOX_NAME" policy add observability-otlp-local --yes ``` The preset permits only `POST /v1/traces` to `host.openshell.internal:4318` from `/opt/venv/bin/python3*`. diff --git a/docs/monitoring/verify-deepagents-trace-export.mdx b/docs/monitoring/verify-deepagents-trace-export.mdx index 2c90d0ad5ce..b169d0e744d 100644 --- a/docs/monitoring/verify-deepagents-trace-export.mdx +++ b/docs/monitoring/verify-deepagents-trace-export.mdx @@ -53,7 +53,7 @@ For additional collector diagnostics, refer to [Troubleshooting the OpenTelemetr | --- | --- | | Collector exits at startup | Run the validation command again, then inspect `docker logs nemoclaw-otel-langsmith`. Confirm that the image is the Contrib `0.155.0` image and that all four `LANGSMITH_*` variables were set when the container was created. | | Port `4318` is already allocated | Run `ss -ltnp 'sport = :4318'` and stop the conflicting listener. The managed sandbox endpoint is fixed, so changing the collector port does not work. | -| Collector is healthy but logs no trace count | Run `policy-list` and add `observability-otlp-local` if it is absent. Confirm that `docker port` shows the current `$OTLP_BIND_IP`. Run `nemo-deepagents rebuild --observability --yes` if the sandbox was started without the opt-in. | +| Collector is healthy but logs no trace count | Run `policy list` and add `observability-otlp-local` if it is absent. Confirm that `docker port` shows the current `$OTLP_BIND_IP`. Run `nemo-deepagents rebuild --observability --yes` if the sandbox was started without the opt-in. | | Collector logs `401` | Replace an invalid or expired LangSmith API key, then recreate the collector. | | Collector logs `403` | Confirm that the service key can write to the target workspace and that `LANGSMITH_WORKSPACE_ID` matches that workspace. Organization-scoped service keys require `X-Tenant-Id`. | | Collector logs `404` | Confirm that `LANGSMITH_OTLP_TRACES_ENDPOINT` uses the correct US, EU, GCP-hosted APAC, AWS-hosted US, or self-hosted API base and ends in `/otel/v1/traces`. | diff --git a/docs/network-policy/apply-policy-presets.mdx b/docs/network-policy/apply-policy-presets.mdx index 4b0e1f27075..68468063dc5 100644 --- a/docs/network-policy/apply-policy-presets.mdx +++ b/docs/network-policy/apply-policy-presets.mdx @@ -5,7 +5,7 @@ title: "Apply Policy Presets" sidebar-title: "Apply Policy Presets" description: "Add, reapply, list, or remove policy presets for a running NemoClaw sandbox." description-agent: "Applies and manages policy presets for a running sandbox. Use when adding maintained integration access, previewing preset scope, reapplying an edited preset, or removing access." -keywords: ["nemoclaw policy presets", "policy-add", "policy-remove"] +keywords: ["nemoclaw policy presets", "policy add", "policy remove"] content: type: "how_to" skill: @@ -15,7 +15,7 @@ Use policy presets to add reviewed network access to one running sandbox without NemoClaw records applied presets so rebuild and restore operations can replay them. -Use `$$nemoclaw policy-add` to merge a preset into the running policy. +Use `$$nemoclaw policy add` to merge a preset into the running policy. The OpenShell `policy set` command replaces the live policy instead of merging it. Follow [Replace the Live Network Policy](replace-live-network-policy) only when you need full-policy replacement. @@ -29,7 +29,7 @@ Messaging channel choices are scoped to the active agent, so unsupported channel List the presets available to the sandbox: ```bash -$$nemoclaw policy-list +$$nemoclaw policy list ``` For the maintained preset catalog and guided service workflows, refer to [Common Integration Policy Examples](../integration-policy-examples). @@ -39,14 +39,14 @@ For the maintained preset catalog and guided service workflows, refer to [Common Use `--dry-run` to review the endpoints, rules, and binaries before applying the preset: ```bash -$$nemoclaw my-assistant policy-add pypi --dry-run -$$nemoclaw my-assistant policy-add pypi --yes +$$nemoclaw my-assistant policy add pypi --dry-run +$$nemoclaw my-assistant policy add pypi --yes ``` Omit the preset name to use the interactive picker: ```bash -$$nemoclaw my-assistant policy-add +$$nemoclaw my-assistant policy add ``` Pass a preset name with `--yes` for scripted workflows. @@ -54,7 +54,7 @@ Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` to use the same non-interact ## Reapply an Edited Preset -Run the same `policy-add` command after you edit a maintained or recorded custom preset. +Run the same `policy add` command after you edit a maintained or recorded custom preset. NemoClaw compares the preset with the live policy. If the content differs, it applies the changed content. You do not need to remove the preset first. @@ -68,16 +68,16 @@ Existing presets and baseline entries remain in place. List every preset recorded for the sandbox: ```bash -$$nemoclaw policy-list +$$nemoclaw policy list ``` Remove a preset when the sandbox no longer needs its access: ```bash -$$nemoclaw my-assistant policy-remove pypi --yes +$$nemoclaw my-assistant policy remove pypi --yes ``` -`policy-remove` accepts maintained and custom preset names. +`policy remove` accepts maintained and custom preset names. ## Understand Persistence diff --git a/docs/network-policy/change-baseline-network-policy.mdx b/docs/network-policy/change-baseline-network-policy.mdx index 2059b37e0d2..c05ea37d87c 100644 --- a/docs/network-policy/change-baseline-network-policy.mdx +++ b/docs/network-policy/change-baseline-network-policy.mdx @@ -81,7 +81,7 @@ Check that the sandbox is running with the updated policy: $$nemoclaw status ``` -Use `$$nemoclaw policy-list` to inspect the tracked preset state. +Use `$$nemoclaw policy list` to inspect the tracked preset state. Use `openshell policy get ` when you need to inspect the effective OpenShell policy. ## Related Topics diff --git a/docs/network-policy/configure-raw-tls-passthrough.mdx b/docs/network-policy/configure-raw-tls-passthrough.mdx index 47b200e5937..39634fc8838 100644 --- a/docs/network-policy/configure-raw-tls-passthrough.mdx +++ b/docs/network-policy/configure-raw-tls-passthrough.mdx @@ -50,7 +50,7 @@ The filename without `.yaml` must match `preset.name`. Apply the catalog preset: ```bash -$$nemoclaw my-assistant policy-add cf-fronted-api +$$nemoclaw my-assistant policy add cf-fronted-api ``` Run the same command after editing the file. diff --git a/docs/network-policy/create-custom-policy-presets.mdx b/docs/network-policy/create-custom-policy-presets.mdx index 29a0c69b2c0..9ccddb86013 100644 --- a/docs/network-policy/create-custom-policy-presets.mdx +++ b/docs/network-policy/create-custom-policy-presets.mdx @@ -5,7 +5,7 @@ title: "Create Custom Policy Presets" sidebar-title: "Create Custom Presets" description: "Author and apply a scoped network policy preset for an endpoint that NemoClaw does not include." description-agent: "Creates and applies custom policy presets. Use when adding a reviewed endpoint, applying preset files, or allowing a URL-based MCP server." -keywords: ["nemoclaw custom policy preset", "policy-add from-file", "url mcp network policy"] +keywords: ["nemoclaw custom policy preset", "policy add from-file", "url mcp network policy"] content: type: "how_to" skill: @@ -68,8 +68,8 @@ The only exception is the `host.openshell.internal` bridge endpoint for explicit Preview the file before you apply it: ```bash -$$nemoclaw my-assistant policy-add --from-file ./presets/my-service-api.yaml --dry-run -$$nemoclaw my-assistant policy-add --from-file ./presets/my-service-api.yaml --yes +$$nemoclaw my-assistant policy add --from-file ./presets/my-service-api.yaml --dry-run +$$nemoclaw my-assistant policy add --from-file ./presets/my-service-api.yaml --yes ``` NemoClaw records the complete YAML content with the sandbox. @@ -80,7 +80,7 @@ You can remove the preset later without keeping the original file. Apply preset files in lexicographic order: ```bash -$$nemoclaw my-assistant policy-add --from-dir ./presets/ --yes +$$nemoclaw my-assistant policy add --from-dir ./presets/ --yes ``` Processing stops at the first failure. @@ -91,13 +91,13 @@ Fix the failing file and run the command again to continue. Save a maintained local preset under `nemoclaw-blueprint/policies/presets/`. The filename without `.yaml` must match `preset.name`. -The preset catalog reads `preset.name`, while `policy-add ` loads `presets/.yaml`. +The preset catalog reads `preset.name`, while `policy add ` loads `presets/.yaml`. A mismatch can list a preset that the named command cannot load. Apply the catalog preset by name: ```bash -$$nemoclaw my-assistant policy-add my-service-api +$$nemoclaw my-assistant policy add my-service-api ``` Run the same command after editing the file. @@ -108,10 +108,10 @@ NemoClaw compares the preset with the live policy and applies changed content. Remove the preset by its recorded name: ```bash -$$nemoclaw my-assistant policy-remove my-service-api --yes +$$nemoclaw my-assistant policy remove my-service-api --yes ``` -Run `$$nemoclaw policy-list` to see every maintained and custom preset recorded for the sandbox. +Run `$$nemoclaw policy list` to see every maintained and custom preset recorded for the sandbox. ## Configure a URL-Based MCP Server @@ -160,7 +160,7 @@ Save the file as `nemoclaw-blueprint/policies/presets/my-mcp.yaml`. Apply it by name: ```bash -$$nemoclaw my-assistant policy-add my-mcp +$$nemoclaw my-assistant policy add my-mcp ``` NemoClaw previews the effective egress scope and prompts for confirmation before applying it. diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 1b6951c209d..b079a8940b8 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -26,7 +26,7 @@ NemoClaw declares sandbox policy in YAML, and [NVIDIA OpenShell](https://github. If a sandbox needs an HTTP service on the host, expose the service on a host IP that the OpenShell gateway can reach. -Apply a custom preset with `$$nemoclaw policy-add --from-file`. +Apply a custom preset with `$$nemoclaw policy add --from-file`. Do not rely on `host.docker.internal` as a general host-service path because it bypasses the OpenShell policy path and may not be reachable. Refer to [Agent cannot reach a host-side HTTP service](../reference/troubleshooting#agent-cannot-reach-a-host-side-http-service). diff --git a/docs/network-policy/explain-network-policy-to-agents.mdx b/docs/network-policy/explain-network-policy-to-agents.mdx index d4bdd937ad0..dea65530491 100644 --- a/docs/network-policy/explain-network-policy-to-agents.mdx +++ b/docs/network-policy/explain-network-policy-to-agents.mdx @@ -5,13 +5,13 @@ title: "Explain Network Policy to Agents" sidebar-title: "Explain Policy to Agents" description: "Generate a redacted policy summary that helps an agent classify network failures." description-agent: "Explains the active network policy to sandbox agents. Use when generating a redacted policy summary, interpreting verification status, or classifying network failures." -keywords: ["nemoclaw policy-explain", "agent network policy context", "POLICY.md"] +keywords: ["nemoclaw policy explain", "agent network policy context", "POLICY.md"] content: type: "how_to" skill: priority: 10 --- -Use `policy-explain` to give a sandbox agent a compact, redacted view of its active network policy. +Use `policy explain` to give a sandbox agent a compact, redacted view of its active network policy. The summary helps the agent distinguish policy denials, missing credentials, unsupported capabilities, and upstream failures. ## Print the Policy Context @@ -19,22 +19,22 @@ The summary helps the agent distinguish policy denials, missing credentials, uns Print the redacted summary: ```bash -$$nemoclaw my-assistant policy-explain +$$nemoclaw my-assistant policy explain ``` Pass `--json` when a tool needs a structured object: ```bash -$$nemoclaw my-assistant policy-explain --json +$$nemoclaw my-assistant policy explain --json ``` During OpenClaw onboarding, NemoClaw writes the rendered context to `/sandbox/.openclaw/workspace/POLICY.md`. -It refreshes that file after `policy-add` or `policy-remove`. +It refreshes that file after `policy add` or `policy remove`. Refresh the file without changing policy: ```bash -$$nemoclaw my-assistant policy-explain --write +$$nemoclaw my-assistant policy explain --write ``` @@ -85,7 +85,7 @@ A block code on a host from a verified preset stays `unknown` because the gatewa Each verdict includes `confidence` set to `high` or `low`. Low confidence means the agent must report multiple possibilities instead of treating one next step as authoritative. -For `blocked-by-policy`, run `$$nemoclaw policy-add ` or follow [Custom Preset Files](customize-network-policy#custom-preset-files). +For `blocked-by-policy`, run `$$nemoclaw policy add ` or follow [Custom Preset Files](customize-network-policy#custom-preset-files). For `missing-approval`, confirm the API token and scopes. For `unsupported`, surface the limitation without retrying. diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index c7698bbeadb..0d1fafd752e 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -29,7 +29,7 @@ Replace `my-assistant` with your sandbox name in the examples. Check the current policy state first: ```bash -$$nemoclaw my-assistant policy-list +$$nemoclaw my-assistant policy list ``` For a live view of blocked requests, open the OpenShell TUI in a separate host terminal: @@ -40,12 +40,12 @@ openshell term When the agent reaches an endpoint that is not in the policy, the TUI shows the host, port, requesting binary, method, and path when available. Approve a request only when you understand why the integration needs it. -An approval updates the running policy, but it does not create a reviewable NemoClaw preset entry that `policy-add` can replay. +An approval updates the running policy, but it does not create a reviewable NemoClaw preset entry that `policy add` can replay. ## Supported Integration Presets NemoClaw ships maintained policy presets for common services in `nemoclaw-blueprint/policies/presets/`. -Messaging channel presets are scoped to the sandbox's active agent; if an agent does not have a matching channel policy, that channel preset is omitted from `policy-list` and `policy-add ` reports it as unknown. +Messaging channel presets are scoped to the sandbox's active agent; if an agent does not have a matching channel policy, that channel preset is omitted from `policy list` and `policy add ` reports it as unknown. | Workflow | Preset | Agent support | |----------|--------|---------------| @@ -72,22 +72,22 @@ Messaging channel presets are scoped to the sandbox's active agent; if an agent Preview the endpoints before applying: ```bash -$$nemoclaw my-assistant policy-add outlook --dry-run +$$nemoclaw my-assistant policy add outlook --dry-run ``` -NemoClaw generates the preview from the exact preset policy YAML that `policy-add` would apply. +NemoClaw generates the preview from the exact preset policy YAML that `policy add` would apply. It lists hosts, ports, access, protocol, TLS, and enforcement settings, HTTP methods and paths, and the binary allowlist. Apply the preset: ```bash -$$nemoclaw my-assistant policy-add outlook --yes +$$nemoclaw my-assistant policy add outlook --yes ``` Remove it later if the sandbox no longer needs that access: ```bash -$$nemoclaw my-assistant policy-remove outlook --yes +$$nemoclaw my-assistant policy remove outlook --yes ``` ## Email and Calendar With Microsoft 365 @@ -96,11 +96,11 @@ Use the `outlook` preset for Microsoft 365 email and calendar workflows that use The preset allows access to `graph.microsoft.com`, Microsoft login, and Outlook service endpoints. ```bash -$$nemoclaw my-assistant policy-add outlook --dry-run -$$nemoclaw my-assistant policy-add outlook --yes +$$nemoclaw my-assistant policy add outlook --dry-run +$$nemoclaw my-assistant policy add outlook --yes ``` -When the `outlook` preset is already applied, `policy-add outlook` compares the preset with the live policy. +When the `outlook` preset is already applied, `policy add outlook` compares the preset with the live policy. If they match, the command exits zero without changing the live policy. If they differ, the command applies the preset again. This includes changes to the preset file. @@ -119,7 +119,7 @@ Telegram needs channel configuration and egress policy. If you already enabled Telegram during onboarding but did not include the preset, add it to the running sandbox: ```bash -$$nemoclaw my-assistant policy-add telegram --yes +$$nemoclaw my-assistant policy add telegram --yes ``` To add Telegram after onboarding, set the token on the host, add the channel, rebuild so the image picks up the channel config, and apply the policy preset: @@ -128,7 +128,7 @@ To add Telegram after onboarding, set the token on the host, add the channel, re export TELEGRAM_BOT_TOKEN= NEMOCLAW_NON_INTERACTIVE=1 $$nemoclaw my-assistant channels add telegram $$nemoclaw my-assistant rebuild -$$nemoclaw my-assistant policy-add telegram --yes +$$nemoclaw my-assistant policy add telegram --yes ``` If delivery fails, open the TUI and send a test message to the bot: @@ -151,7 +151,7 @@ export SLACK_BOT_TOKEN= export SLACK_APP_TOKEN= NEMOCLAW_NON_INTERACTIVE=1 $$nemoclaw my-assistant channels add slack $$nemoclaw my-assistant rebuild -$$nemoclaw my-assistant policy-add slack --yes +$$nemoclaw my-assistant policy add slack --yes ``` For Discord: @@ -161,14 +161,14 @@ export DISCORD_BOT_TOKEN= export DISCORD_SERVER_ID= NEMOCLAW_NON_INTERACTIVE=1 $$nemoclaw my-assistant channels add discord $$nemoclaw my-assistant rebuild -$$nemoclaw my-assistant policy-add discord --yes +$$nemoclaw my-assistant policy add discord --yes ``` If you enabled Slack or Discord during onboarding, apply only the matching preset: ```bash -$$nemoclaw my-assistant policy-add slack --yes -$$nemoclaw my-assistant policy-add discord --yes +$$nemoclaw my-assistant policy add slack --yes +$$nemoclaw my-assistant policy add discord --yes ``` ## WeChat or WhatsApp Messaging (Experimental) @@ -184,7 +184,7 @@ Add the channel interactively and apply the preset: ```bash $$nemoclaw my-assistant channels add wechat $$nemoclaw my-assistant rebuild -$$nemoclaw my-assistant policy-add wechat --yes +$$nemoclaw my-assistant policy add wechat --yes ``` WhatsApp Web pairs entirely inside the sandbox through QR scan, so `channels add` does not collect a host-side token. @@ -193,14 +193,14 @@ Apply the preset and complete the in-sandbox pairing after the rebuild: ```bash NEMOCLAW_NON_INTERACTIVE=1 $$nemoclaw my-assistant channels add whatsapp $$nemoclaw my-assistant rebuild -$$nemoclaw my-assistant policy-add whatsapp --yes +$$nemoclaw my-assistant policy add whatsapp --yes ``` If you enabled WeChat or WhatsApp during onboarding, apply only the matching preset: ```bash -$$nemoclaw my-assistant policy-add wechat --yes -$$nemoclaw my-assistant policy-add whatsapp --yes +$$nemoclaw my-assistant policy add wechat --yes +$$nemoclaw my-assistant policy add whatsapp --yes ``` ## GitHub and Jira @@ -211,15 +211,15 @@ Use `jira` when the agent needs Atlassian Jira access. Preview first: ```bash -$$nemoclaw my-assistant policy-add github --dry-run -$$nemoclaw my-assistant policy-add jira --dry-run +$$nemoclaw my-assistant policy add github --dry-run +$$nemoclaw my-assistant policy add jira --dry-run ``` Apply the preset that matches the workflow: ```bash -$$nemoclaw my-assistant policy-add github --yes -$$nemoclaw my-assistant policy-add jira --yes +$$nemoclaw my-assistant policy add github --yes +$$nemoclaw my-assistant policy add jira --yes ``` The `jira` preset intentionally allows Node.js access to Atlassian Cloud and does not allow `curl`. @@ -242,8 +242,8 @@ This manual probe proves curl reached Atlassian, but no Jira credentials were su Remove access when the task is done: ```bash -$$nemoclaw my-assistant policy-remove github --yes -$$nemoclaw my-assistant policy-remove jira --yes +$$nemoclaw my-assistant policy remove github --yes +$$nemoclaw my-assistant policy remove jira --yes ``` ## Web Search @@ -260,15 +260,15 @@ Apply only the preset that matches the provider you selected during onboarding. Use these commands for Brave Search. ```bash -$$nemoclaw my-assistant policy-add brave --dry-run -$$nemoclaw my-assistant policy-add brave --yes +$$nemoclaw my-assistant policy add brave --dry-run +$$nemoclaw my-assistant policy add brave --yes ``` Use these commands for Tavily Search. ```bash -$$nemoclaw my-assistant policy-add tavily --dry-run -$$nemoclaw my-assistant policy-add tavily --yes +$$nemoclaw my-assistant policy add tavily --dry-run +$$nemoclaw my-assistant policy add tavily --yes ``` Rerun onboarding when you change providers because the OpenClaw plugin configuration and OpenShell credential attachment are part of the sandbox image. @@ -281,8 +281,8 @@ Hermes supports Tavily Search through NemoClaw onboarding and does not support B Apply the `tavily` preset if it is missing. ```bash -$$nemoclaw my-assistant policy-add tavily --dry-run -$$nemoclaw my-assistant policy-add tavily --yes +$$nemoclaw my-assistant policy add tavily --dry-run +$$nemoclaw my-assistant policy add tavily --yes ``` Rerun onboarding when you enable or disable Tavily because the Hermes backend and OpenShell credential attachment are part of the sandbox image. @@ -298,20 +298,20 @@ OpenShell replaces that placeholder at egress, so the raw key is not written int Use the `weather` preset when the agent needs read-only weather or geocoding lookups. The Open tier includes it by default. -Balanced and Restricted sandboxes require the explicit `policy-add weather` step below. +Balanced and Restricted sandboxes require the explicit `policy add weather` step below. The preset covers read-only public weather and geocoding lookups, including current conditions, forecasts, geocoding, and weather alerts, without enabling messaging or productivity APIs. ```bash -$$nemoclaw my-assistant policy-add weather --dry-run -$$nemoclaw my-assistant policy-add weather --yes +$$nemoclaw my-assistant policy add weather --dry-run +$$nemoclaw my-assistant policy add weather --yes ``` Use the `public-reference` preset when the agent needs read-only public reference APIs, such as Wikipedia, Wikidata, Wikimedia Commons, Nominatim, or country metadata. The Open tier includes this preset by default. ```bash -$$nemoclaw my-assistant policy-add public-reference --dry-run -$$nemoclaw my-assistant policy-add public-reference --yes +$$nemoclaw my-assistant policy add public-reference --dry-run +$$nemoclaw my-assistant policy add public-reference --yes ``` ## Package and Model Tooling @@ -328,19 +328,19 @@ Use these presets when an agent workflow needs to install packages or download m Add only the preset required for the task: ```bash -$$nemoclaw my-assistant policy-add npm --yes -$$nemoclaw my-assistant policy-add pypi --yes -$$nemoclaw my-assistant policy-add brew --yes -$$nemoclaw my-assistant policy-add huggingface --yes +$$nemoclaw my-assistant policy add npm --yes +$$nemoclaw my-assistant policy add pypi --yes +$$nemoclaw my-assistant policy add brew --yes +$$nemoclaw my-assistant policy add huggingface --yes ``` Remove package access after a one-time setup task if the sandbox no longer needs it: ```bash -$$nemoclaw my-assistant policy-remove npm --yes -$$nemoclaw my-assistant policy-remove pypi --yes -$$nemoclaw my-assistant policy-remove brew --yes -$$nemoclaw my-assistant policy-remove huggingface --yes +$$nemoclaw my-assistant policy remove npm --yes +$$nemoclaw my-assistant policy remove pypi --yes +$$nemoclaw my-assistant policy remove brew --yes +$$nemoclaw my-assistant policy remove huggingface --yes ``` The `pypi` preset allows Python, `pip`, virtual-environment Python and `pip`, and `/usr/local/bin/uv` to reach PyPI endpoints. @@ -353,7 +353,7 @@ A `/usr/local/bin/brew` wrapper puts the entry point on the sandbox `PATH` while Installed formula commands are available from the Linuxbrew bin directory in sandbox shell sessions: ```bash -$$nemoclaw my-assistant policy-add brew --yes +$$nemoclaw my-assistant policy add brew --yes $$nemoclaw my-assistant exec -- brew install $$nemoclaw my-assistant exec -- bash -lc '' ``` @@ -374,8 +374,8 @@ Apply the `openclaw-pricing` preset to allow both pricing endpoints. The preset pins each host to a single read-only path so it does not widen egress beyond the pricing fetch: ```bash -$$nemoclaw my-assistant policy-add openclaw-pricing --dry-run -$$nemoclaw my-assistant policy-add openclaw-pricing --yes +$$nemoclaw my-assistant policy add openclaw-pricing --dry-run +$$nemoclaw my-assistant policy add openclaw-pricing --yes ``` After the next gateway restart, the WARN entries stop and `usage.cost` populates from the fetched pricing tables. @@ -394,8 +394,8 @@ Onboarding auto-suggests this preset when you choose a local provider. If you need to add it after onboarding: ```bash -$$nemoclaw my-assistant policy-add local-inference --dry-run -$$nemoclaw my-assistant policy-add local-inference --yes +$$nemoclaw my-assistant policy add local-inference --dry-run +$$nemoclaw my-assistant policy add local-inference --yes ``` Then verify the sandbox status: @@ -411,7 +411,7 @@ Follow [Set Up Gmail With an App Password](set-up-gmail-with-an-app-password) fo ## Inspect or Replace the Live Policy -Use `policy-list` for normal preset state. +Use `policy list` for normal preset state. Follow [Replace the Live Network Policy](configure-policies/replace-live-network-policy) only when you need to export, edit, and replace the complete policy. Use [Apply Policy Presets](configure-policies/apply-policy-presets) to merge maintained or custom preset entries. @@ -421,4 +421,4 @@ Use [Apply Policy Presets](configure-policies/apply-policy-presets) to merge mai - [Customize the Sandbox Network Policy](customize-network-policy) to choose the correct policy workflow. - [Set Up Gmail With an App Password](set-up-gmail-with-an-app-password) for the full Gmail IMAP and SMTP workflow. - [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) for Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams configuration. -- [Commands](../reference/commands) for the full `policy-get`, `policy-add`, `policy-list`, `policy-remove`, and `channels` command reference. +- [Commands](../reference/commands) for the full `policy get`, `policy add`, `policy list`, `policy remove`, and `channels` command reference. diff --git a/docs/network-policy/replace-live-network-policy.mdx b/docs/network-policy/replace-live-network-policy.mdx index aea08dc4377..fd152b572ee 100644 --- a/docs/network-policy/replace-live-network-policy.mdx +++ b/docs/network-policy/replace-live-network-policy.mdx @@ -5,7 +5,7 @@ title: "Replace the Live Network Policy" sidebar-title: "Replace the Live Policy" description: "Export, edit, validate, and replace the complete live policy for a NemoClaw sandbox." description-agent: "Replaces the complete live sandbox policy. Use when a full policy edit cannot use NemoClaw policy presets." -keywords: ["openshell policy set", "nemoclaw policy-get", "replace live network policy"] +keywords: ["openshell policy set", "nemoclaw policy get", "replace live network policy"] content: type: "how_to" skill: @@ -26,7 +26,7 @@ Applying a file that contains only one part silently removes the other entries. Start from the current round-trippable base policy so the applied presets remain in the file: ```bash -$$nemoclaw my-assistant policy-get > current-policy.yaml +$$nemoclaw my-assistant policy get > current-policy.yaml ``` The command retrieves and validates the base policy. @@ -59,7 +59,7 @@ For a durable source-of-truth change, update the baseline policy or record a cus Inspect the tracked preset state: ```bash -$$nemoclaw my-assistant policy-list +$$nemoclaw my-assistant policy list ``` Inspect the effective OpenShell policy before testing the endpoint: diff --git a/docs/network-policy/set-up-gmail-with-an-app-password.mdx b/docs/network-policy/set-up-gmail-with-an-app-password.mdx index db39ba57ec6..5b8b9e1f8e1 100644 --- a/docs/network-policy/set-up-gmail-with-an-app-password.mdx +++ b/docs/network-policy/set-up-gmail-with-an-app-password.mdx @@ -39,8 +39,8 @@ Google documents the TLS endpoints and ports in [IMAP, POP, and SMTP](https://de Run these commands from the host: ```bash -$$nemoclaw my-assistant policy-add gmail --dry-run -$$nemoclaw my-assistant policy-add gmail --yes +$$nemoclaw my-assistant policy add gmail --dry-run +$$nemoclaw my-assistant policy add gmail --yes ``` ## Upload the App Password @@ -174,7 +174,7 @@ rm -f /path/to/gmail_config.json $$nemoclaw my-assistant exec -- rm -f /sandbox/hand/gmail_config.json $$nemoclaw my-assistant exec -- rm -f /sandbox/hand/download_attachments.py /sandbox/hand/send_email.py $$nemoclaw my-assistant exec -- rm -rf /sandbox/hand/gmail_attachments -$$nemoclaw my-assistant policy-remove gmail --yes +$$nemoclaw my-assistant policy remove gmail --yes ``` Removing the preset does not delete uploaded files or revoke the App Password. diff --git a/docs/reference/cli-selection-guide.mdx b/docs/reference/cli-selection-guide.mdx index 933f07ba831..b17b42e12ba 100644 --- a/docs/reference/cli-selection-guide.mdx +++ b/docs/reference/cli-selection-guide.mdx @@ -64,9 +64,9 @@ For a completed sandbox, use `--fresh --name --recreate-sandbox` - Add or remove NemoClaw policy presets: ```bash - $$nemoclaw my-assistant policy-add pypi --yes - $$nemoclaw my-assistant policy-list - $$nemoclaw my-assistant policy-remove pypi --yes + $$nemoclaw my-assistant policy add pypi --yes + $$nemoclaw my-assistant policy list + $$nemoclaw my-assistant policy remove pypi --yes ``` - Manage NemoClaw credentials, diagnostics, and cleanup: @@ -142,7 +142,7 @@ Use `openshell` when the docs explicitly call for a live OpenShell gateway opera Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. ```bash - $$nemoclaw policy-get > current-policy.yaml + $$nemoclaw policy get > current-policy.yaml ``` NemoClaw strips the OpenShell metadata header and exits non-zero if it cannot validate the base policy. @@ -156,7 +156,7 @@ Use `openshell` when the docs explicitly call for a live OpenShell gateway opera `openshell policy update` merges specific endpoint and rule changes into the live sandbox policy. `openshell policy set` replaces the live policy with the file you provide. -For normal NemoClaw network access changes, prefer `$$nemoclaw policy-add` so NemoClaw preserves presets and records the change for rebuilds. +For normal NemoClaw network access changes, prefer `$$nemoclaw policy add` so NemoClaw preserves presets and records the change for rebuilds. ## Common Decisions @@ -259,11 +259,11 @@ $$nemoclaw status ### Update Network Policy -Use `$$nemoclaw policy-add` or `policy-remove` for NemoClaw presets and custom preset files. +Use `$$nemoclaw policy add` or `policy remove` for NemoClaw presets and custom preset files. NemoClaw merges the new policy with the live policy and reapplies presets during rebuilds. Use `openshell policy update` for precise live endpoint or REST rule changes. -Use `$$nemoclaw policy-get` and `openshell policy set --policy --wait ` only when you need to edit and replace the round-trippable base policy. +Use `$$nemoclaw policy get` and `openshell policy set --policy --wait ` only when you need to edit and replace the round-trippable base policy. Use `openshell policy get --full ` only to inspect the effective policy, including provider-composed rules. ### Move Workspace Files diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index dec5a606516..efc78970123 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -452,7 +452,7 @@ 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. +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. @@ -1233,7 +1233,12 @@ NemoClaw resolves the sandbox's recorded owning OpenShell gateway before queryin If another gateway is active, it selects the owner and queries again instead of trusting a result from the sibling gateway. Pass `--json` to emit a structured per-sandbox report instead of the text renderer. -The JSON output includes at least `schemaVersion`, `name`, `found`, `agent`, `agentDisplayName`, `agentRuntime`, `dcodeAutoApprovalMode`, `model`, `provider`, `recordedRoute`, `liveRoute`, `routeDrift`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `failureLayer`, `terminalRuntimeHealth`, `servingProcessHealth`, and `dockerPaused`. +The JSON output includes at least `schemaVersion`, `name`, `found`, `agent`, `agentDisplayName`, `agentRuntime`, `dcodeAutoApprovalMode`, `model`, `provider`, `recordedRoute`, `liveRoute`, `routeDrift`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `baselineExclusions`, `baselineExclusionStates`, `baselineExclusionTransition`, `failureLayer`, `terminalRuntimeHealth`, `servingProcessHealth`, and `dockerPaused`. +`baselineExclusions` is an array of exact baseline keys recorded for durable replay and is empty when the sandbox has none. +`baselineExclusionStates` reports each recorded key with its current verification state. +The `excluded` state means the reviewed entry still matches the active agent baseline and the key is absent from the live OpenShell policy. +Other states identify agent drift, changed or removed baseline content, an unreadable baseline or live policy, or a live policy that contains the excluded key. +`baselineExclusionTransition` is `null` when policy state is settled; otherwise it identifies the interrupted `exclude` or `restore` key that must be reconciled before sandbox creation or recreation, rebuild, or cross-sandbox snapshot cloning. The schema-version `1` `model` and `provider` fields keep their established live-route meaning when the gateway route is readable. Use `recordedRoute` for the sandbox's durable provider and model and `liveRoute` for the gateway-global route. When the live shared route differs, text output prints both routes and JSON output sets `routeDrift.live`, `routeDrift.recorded`, and `routeDrift.canConnect`. @@ -1420,6 +1425,10 @@ For gateway runtimes, `doctor` also reports an informational `Serving process: n This result does not fail the readiness check. Terminal runtimes omit it because they have no long-running gateway process. +For each recorded baseline exclusion, `doctor` compares the approval with the active agent baseline and verifies that the excluded key is absent from the live OpenShell policy. +An unreadable live policy produces a warning because NemoClaw cannot verify enforcement. +A live policy that contains the excluded key fails the check and requires policy repair before you rely on the exclusion. + Warnings do not make the command fail. Failed checks, including a failed or unavailable authoritative inference route, exit non-zero so scripts can use `doctor` as a readiness gate. Use `--json` for machine-readable output. @@ -1678,7 +1687,7 @@ Managed MCP ownership disables the local-only fallback because exact provider cl $$nemoclaw my-assistant destroy [--yes|-y|--force] [--cleanup-gateway|--no-cleanup-gateway] ``` -### `$$nemoclaw policy-get` +### `$$nemoclaw policy get` Export the sandbox's round-trippable OpenShell base policy as YAML. The command runs `openshell policy get --base`, validates the returned policy, and strips the OpenShell metadata header. @@ -1686,13 +1695,13 @@ The default output is suitable for review, editing, and later use with `openshel The command exits non-zero when OpenShell fails, returns an empty response, or returns content that is not valid policy YAML. ```bash -$$nemoclaw my-assistant policy-get > current-policy.yaml +$$nemoclaw my-assistant policy get > current-policy.yaml ``` Use `--raw` only to inspect the unparsed OpenShell response, including its metadata header: ```bash -$$nemoclaw my-assistant policy-get --raw +$$nemoclaw my-assistant policy get --raw ``` Do not pass `--raw` output to `openshell policy set` because the metadata header is not part of the policy document. @@ -1701,7 +1710,7 @@ Do not pass `--raw` output to `openshell policy set` because the metadata header |------|-------------| | `--raw` | Print the unparsed `openshell policy get --base` response, including its metadata header. | -### `$$nemoclaw policy-add` +### `$$nemoclaw policy add` Add a policy preset to a sandbox. Presets extend the baseline network policy with additional endpoints. @@ -1710,13 +1719,13 @@ The scope comes from the exact preset YAML and includes each endpoint's host, po When a lifecycle operation reapplies a preset, NemoClaw compares it with the live policy and reports whether the preset opens new egress, replaces a drifted entry, or is already effective with no new egress. ```bash -$$nemoclaw my-assistant policy-add +$$nemoclaw my-assistant policy add ``` To apply a specific preset without the interactive picker, pass its name as a positional argument: ```bash -$$nemoclaw my-assistant policy-add pypi --yes +$$nemoclaw my-assistant policy add pypi --yes ``` The positional form is required in scripted workflows. @@ -1730,13 +1739,16 @@ The comparison requires both the preset content and the live policy. If either cannot be read, the command exits non-zero. The command also exits non-zero when the name belongs to a custom preset applied with `--from-file`. Use `--from-file` to apply that custom preset again. -Built-in preset choices are scoped to the sandbox's active agent. Messaging channel presets appear only when NemoClaw has a matching channel policy for that agent; unavailable channel presets use the standard unknown-preset error before endpoint preview or confirmation. +Built-in preset choices are scoped to the sandbox's active agent. +Messaging channel presets appear only when NemoClaw has a matching channel policy for that agent; unavailable channel presets use the standard unknown-preset error before endpoint preview or confirmation. +When a baseline key is durably excluded, NemoClaw reserves that key and refuses built-in, custom, channel, and MCP policy additions that would define it again. +Restore the baseline entry before applying a preset that intentionally owns the same key, or rename a custom preset entry whose key represents different access. Custom preset files are tracked with the sandbox that applied them. -`policy-list`, `policy-add`, and `policy-remove` compare the local registry and live gateway state using that sandbox-scoped preset metadata, so custom presets do not appear missing just because they are not part of the built-in preset catalog. -Before `policy-add` writes a merged policy, it reads and parses the round-trippable base policy from OpenShell. +`policy list`, `policy add`, and `policy remove` compare the local registry and live gateway state using that sandbox-scoped preset metadata, so custom presets do not appear missing just because they are not part of the built-in preset catalog. +Before `policy add` writes a merged policy, it reads and parses the round-trippable base policy from OpenShell. If the base policy read returns non-empty output that NemoClaw cannot parse, the command exits non-zero instead of overwriting the live policy with only the new preset. Fix the gateway or policy read problem, then rerun the command. -For custom presets, the command also reports when the preset reached the gateway but NemoClaw could not record it in the local sandbox registry, because unrecorded custom presets will not appear in `policy-list` or `status`. +For custom presets, the command also reports when the preset reached the gateway but NemoClaw could not record it in the local sandbox registry, because unrecorded custom presets will not appear in `policy list` or `status`. Recover or re-onboard the sandbox, then re-apply the custom preset. | Flag | Description | @@ -1749,61 +1761,67 @@ Recover or re-onboard the sandbox, then re-apply the custom preset. Use `--dry-run` to audit a preset before applying it: ```bash -$$nemoclaw my-assistant policy-add --dry-run +$$nemoclaw my-assistant policy add --dry-run ``` Apply a custom preset file when you need to grant access to an endpoint that is not covered by a built-in preset: ```bash -$$nemoclaw my-assistant policy-add --from-file ./presets/my-internal-api.yaml +$$nemoclaw my-assistant policy add --from-file ./presets/my-internal-api.yaml ``` For batch workflows, apply all preset files from a directory: ```bash -$$nemoclaw my-assistant policy-add --from-dir ./presets/ --yes +$$nemoclaw my-assistant policy add --from-dir ./presets/ --yes ``` Review every host in custom preset files before applying them. Custom presets bypass the built-in preset review process and can widen sandbox egress. -### `$$nemoclaw policy-list` +### `$$nemoclaw policy list` List available policy presets and show which ones are applied to the sandbox. The available built-in rows are scoped to the sandbox's active agent, so unsupported messaging channel policies are not listed for agents without matching channel policy files. The command cross-references the local registry against the live gateway state (via `openshell policy get`), so it flags presets that are applied in one place but not the other. This catches desync caused by external edits to the gateway policy or stale registry entries after a manual rollback. Preset summaries come only from the YAML `preset.description` field. -NemoClaw does not render network-policy rule bodies as prose in `policy-list` output. +NemoClaw does not render network-policy rule bodies as prose in `policy list` output. +Recorded baseline exclusions appear in a separate section. +`active` means the reviewed digest still matches the current baseline, `baseline changed — re-review required` means the current entry differs, and `baseline entry removed — restore to clear` means the current release no longer defines the key. +Use `status` or `doctor` to additionally verify that the approval belongs to the active agent and the excluded key is absent from the live policy. +`repair required — interrupted exclude/restore; rebuild blocked` means NemoClaw preserved a durable transaction journal after a crash or persistence failure; rerun the displayed exact policy command to reconcile it before sandbox creation or recreation, rebuild, or cross-sandbox snapshot cloning. Each active preset is annotated with its provenance so you can tell why it is applied: - `[from tier]` — the preset name matches an entry in the sandbox's current tier definition (see [Policy Tiers](../reference/network-policies#policy-tiers)). - `[from agent]` — the preset name matches a NemoClaw-managed agent preset and the active agent matches that label. -- `[user-added]` — anything else: presets applied later through `policy-add`, presets that match no tier or agent default, or presets that match the opposite agent's reserved names on a sandbox running the other agent. -- `[source unverified]` — the row is active but the local registry and live gateway state disagree. When the gateway cannot be queried, this renders as `[source unverified (gateway unreachable)]`. The provenance check is suppressed in these trust-degraded states because the source cannot be confirmed against both halves of the sandbox policy view. +- `[user-added]` — anything else: presets applied later through `policy add`, presets that match no tier or agent default, or presets that match the opposite agent's reserved names on a sandbox running the other agent. +- `[source unverified]` — the row is active but the local registry and live gateway state disagree. + When the gateway cannot be queried, this renders as `[source unverified (gateway unreachable)]`. + The provenance check is suppressed in these trust-degraded states because the source cannot be confirmed against both halves of the sandbox policy view. Provenance tags are inferred from the sandbox's current tier and agent metadata at display time and are not persisted per preset. -A preset whose name appears in the sandbox's current tier YAML is labelled `[from tier]` even when an operator added it manually with `policy-add` after onboarding. +A preset whose name appears in the sandbox's current tier YAML 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 that label. ```bash -$$nemoclaw my-assistant policy-list +$$nemoclaw my-assistant policy list ``` -### `$$nemoclaw policy-remove` +### `$$nemoclaw policy remove` Remove a previously applied policy preset from a sandbox. The command lists only the presets currently applied, prompts you to select one, shows the endpoints that would be removed, and asks for confirmation before narrowing egress. ```bash -$$nemoclaw my-assistant policy-remove +$$nemoclaw my-assistant policy remove ``` To remove a specific preset non-interactively, pass its name as a positional argument: ```bash -$$nemoclaw my-assistant policy-remove pypi --yes +$$nemoclaw my-assistant policy remove pypi --yes ``` Set `NEMOCLAW_NON_INTERACTIVE=1` as an alternative to `--yes`. @@ -1816,20 +1834,51 @@ If the preset is unknown or not currently applied, the command exits non-zero wi Unchecking a preset in the onboard TUI checkbox also removes it from the sandbox. -### `$$nemoclaw policy-explain` +### `$$nemoclaw policy exclude` + +Persistently exclude one exact entry from the agent baseline policy after previewing the egress and support impact that the change removes. +The preview names the supported features that may stop working. +The command refuses an entry that does not have a reviewed feature-impact disclosure. +The versioned exclusion record is bound to the reviewed baseline content and active agent, then replayed during rebuild. +If the active agent or entry changes, rebuild fails closed until you clear or review the exclusion again. +The command refuses to exclude a key that an applied preset already owns, because removing that live key would also remove the preset's access. +The critical `managed_inference` entry cannot currently be excluded pending product direction. +Use `--force` or `--yes` for explicit non-interactive acknowledgement, or `--dry-run` to preview without changing the sandbox. + +```bash +$$nemoclaw my-assistant policy exclude nous_research --dry-run +$$nemoclaw my-assistant policy exclude nous_research --force +``` + +When a release changes an excluded entry, first run `policy restore ` to clear the stale record, preview the current scope with `policy exclude --dry-run`, and then explicitly exclude it again if you still accept the support impact. +When a release removes the entry, run `policy restore ` only to clear the stale exclusion record; there is no replacement scope to review or re-approve. + +### `$$nemoclaw policy restore` + +Restore a previously excluded entry from the current agent baseline and clear its durable exclusion record. +Use `--dry-run` to preview the egress that would be restored. +If a restore is interrupted, NemoClaw finalizes it only when the durable exclusion still exactly matches the staged exclusion and the current release baseline still exactly matches the journaled live target. +If either value changed or the current baseline is unreadable, the journal remains in `repair required` state so you can inspect and re-review the current scope instead of silently accepting a different entry. + +```bash +$$nemoclaw my-assistant policy restore nous_research --dry-run +$$nemoclaw my-assistant policy restore nous_research +``` + +### `$$nemoclaw policy explain` Print a redacted summary of the active policy context for a sandbox so an agent or operator can reason about what is allowed, what is blocked, and how to request a change. -The output covers the recorded tier, the applied presets (built-in and custom) with their allowed host categories, the known presets that are not applied, the inspect/add/remove commands that change policy, and the support boundaries between NemoClaw, OpenShell, and the agent. +The output covers the recorded tier, applied presets and allowed host categories, known unapplied presets, baseline exclusions and their support impact, policy-change commands, and the support boundaries between NemoClaw, OpenShell, and the agent. Raw policy YAML, rule bodies, and credential metadata are deliberately not included. ```bash -$$nemoclaw my-assistant policy-explain +$$nemoclaw my-assistant policy explain ``` Pass `--json` to emit the same context as a structured object for agent consumption: ```bash -$$nemoclaw my-assistant policy-explain --json +$$nemoclaw my-assistant policy explain --json ``` @@ -1837,7 +1886,7 @@ NemoClaw refreshes the rendered context inside the sandbox at `/sandbox/.opencla Pass `--write` to refresh that file on demand without changing the policy: ```bash -$$nemoclaw my-assistant policy-explain --write +$$nemoclaw my-assistant policy explain --write ``` @@ -2584,6 +2633,7 @@ With `--force`, NemoClaw preserves any captured loose files in the partial manif If the backup produced nothing usable, it continues from recorded registry metadata without restoring prior sandbox state. Use this recovery path only when losing the state that could not be backed up is acceptable. Before backup or deletion, `rebuild` also refuses an incomplete MCP destroy transaction. +It also refuses a pending baseline exclusion transaction before opening a shields-down window, starting backup, or deleting the sandbox, and prints the exact `policy exclude` or `policy restore` command to rerun. For a prepared-only transaction, the redacted diagnostic points to `$$nemoclaw mcp remove --force` when the sandbox is still live. For a pending or both-marker transaction, it points to `$$nemoclaw destroy` because the registry records that OpenShell deletion was already confirmed. Before backup or deletion, rebuild checks the staged messaging configuration for credentials or channel resources already used by another registered sandbox. @@ -2699,6 +2749,7 @@ Create a timestamped snapshot of sandbox state. Snapshots are stored in `~/.nemoclaw/rebuild-backups//`. The command requires shields to be down and keeps the shields check and backup under one per-sandbox transition. An expired auto-restore timer can interrupt a long-running backup and restore lockdown. +When the sandbox has active baseline exclusions, successful output lists their keys and repeats that excluded egress leaves dependent agent features unsupported for that sandbox. ```bash $$nemoclaw my-assistant snapshot create @@ -2742,6 +2793,7 @@ The selector accepts any of: Pass `--to ` to restore the snapshot into a different sandbox instead of the source. When `dst` does not exist, it is auto-created by reusing the source sandbox's container image. No re-onboarding is needed. +A cross-sandbox restore refuses to clone a source or replace an existing destination whose baseline exclusion transaction needs repair, before it creates or deletes anything. When `dst` already exists, `snapshot restore --to ` refuses by default to avoid silently mutating the destination's filesystem. To overwrite an existing destination, pass `--force`: the command deletes `dst`, then recreates it from the source's image and restores the snapshot into the fresh copy. If the existing destination has an active shields timer, the force path restores and verifies lockdown, revokes the timer, and then deletes the destination. @@ -3691,7 +3743,8 @@ docker run --rm --name nemoclaw-jaeger \ NEMOCLAW_OPENCLAW_OTEL=1 $$nemoclaw onboard ``` -Onboarding automatically applies the `openclaw-diagnostics-otel-local` preset at sandbox create and again during the policy step when `NEMOCLAW_OPENCLAW_OTEL=1`, so OTLP export is allowed before the gateway's first trace flush. If you enabled OTEL after an existing sandbox was created, run `$$nemoclaw policy-add openclaw-diagnostics-otel-local --yes` or recreate the sandbox with OTEL enabled at build time. +Onboarding automatically applies the `openclaw-diagnostics-otel-local` preset at sandbox create and again during the policy step when `NEMOCLAW_OPENCLAW_OTEL=1`, so OTLP export is allowed before the gateway's first trace flush. +If you enabled OTEL after an existing sandbox was created, run `$$nemoclaw policy add openclaw-diagnostics-otel-local --yes` or recreate the sandbox with OTEL enabled at build time. Then open `http://localhost:16686` and select the `openclaw-gateway` service. The built-in `openclaw-diagnostics-otel-local` preset allows only `POST /v1/traces` (and subpaths) to `host.openshell.internal:4318` from `openclaw` and `node`. diff --git a/docs/reference/enterprise-readiness.mdx b/docs/reference/enterprise-readiness.mdx index 0dd746dde71..7dcceb38b55 100644 --- a/docs/reference/enterprise-readiness.mdx +++ b/docs/reference/enterprise-readiness.mdx @@ -65,8 +65,8 @@ Each row links to deeper documentation and, when a concrete fix is in progress, | Capability area | Status | Notes, workaround, and tracked work | |---|---|---| | Deny-by-default egress and operator approval | Supported | The sandbox blocks all unlisted outbound traffic and surfaces blocked requests for approval in `openshell term`. Approvals persist within a sandbox instance and reset to the baseline when you destroy and recreate it. Refer to [Approve or Deny Network Requests](../network-policy/approve-network-requests). | -| Network policy configuration | Supported | Edit baseline policy in the blueprint, apply presets, or add endpoints to a running sandbox with `$$nemoclaw policy-add --from-file`. Refer to [Customize the Network Policy](../network-policy/customize-network-policy) and [Network Policies](network-policies). | -| Network policy and denial visibility | Supported with caveats | Live activity appears in `openshell term`; lifecycle and gateway output appear in `$$nemoclaw logs`. Denial log readability is being improved in [#4760](https://github.com/NVIDIA/NemoClaw/issues/4760). Default-policy gaps for plugin installs are tracked in [#4104](https://github.com/NVIDIA/NemoClaw/issues/4104) and [#4015](https://github.com/NVIDIA/NemoClaw/issues/4015), and a `policy-add` YAML defect in [#991](https://github.com/NVIDIA/NemoClaw/issues/991). | +| Network policy configuration | Supported | Edit baseline policy in the blueprint, apply presets, or add endpoints to a running sandbox with `$$nemoclaw policy add --from-file`. Refer to [Customize the Network Policy](../network-policy/customize-network-policy) and [Network Policies](network-policies). | +| Network policy and denial visibility | Supported with caveats | Live activity appears in `openshell term`; lifecycle and gateway output appear in `$$nemoclaw logs`. Denial log readability is being improved in [#4760](https://github.com/NVIDIA/NemoClaw/issues/4760). Default-policy gaps for plugin installs are tracked in [#4104](https://github.com/NVIDIA/NemoClaw/issues/4104) and [#4015](https://github.com/NVIDIA/NemoClaw/issues/4015), and a `policy add` YAML defect in [#991](https://github.com/NVIDIA/NemoClaw/issues/991). | | Model and provider switching | Supported | Switch the active provider or model with the NemoClaw inference commands. Some changes rebuild the sandbox image. Refer to [Switch Inference Providers](../inference/manage-inference/switch-providers) and [Choose an Inference Provider](../inference/learn-and-choose/choose-inference-provider). | | Multi-agent and multi-sandbox usage | Supported with caveats | Side-by-side sandboxes run on distinct names and dashboard ports, and each name maps to exactly one agent type. Known multi-instance issues include gateway-port collisions ([#5359](https://github.com/NVIDIA/NemoClaw/issues/5359)) and parallel inference routing fallback ([#5343](https://github.com/NVIDIA/NemoClaw/issues/5343)). A declarative multi-agent manifest is roadmap ([#2853](https://github.com/NVIDIA/NemoClaw/issues/2853)). | | Monitoring and health | Supported | Use `$$nemoclaw status`, `$$nemoclaw logs --follow`, and `openshell term`. Refer to [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity). | diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 49f81de9fe5..4bf5c341387 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -81,7 +81,7 @@ The following endpoint groups are allowed by default: Hermes baseline endpoint groups are declared by the Hermes agent policy additions. -Use `$$nemoclaw policy-list` or `openshell policy get --base ` on a live sandbox to inspect the exact applied baseline. +Use `$$nemoclaw policy list` or `openshell policy get --base ` on a live sandbox to inspect the exact applied baseline. @@ -136,10 +136,12 @@ Apply maintained presets such as `github`, `pypi`, or `tavily` only when the san During onboarding, the wizard prompts for a policy tier that determines the default set of presets applied on top of the baseline policy. The baseline policy is always applied regardless of the selected tier. +An operator can persistently exclude a specific baseline entry with [`policy exclude`](#excluding-a-baseline-entry) when they accept reduced, unsupported functionality. +This is the supported registry-backed way to replay a live exact-key removal during rebuild; raw `openshell policy set` edits are not replayed, while edits made to the source baseline itself are applied when the sandbox is recreated. | Tier | Presets included | Description | |------|------------------|-------------| -| 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. | +| 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. | @@ -170,9 +172,9 @@ When LangChain Deep Agents Code is onboarded with `--observability`, NemoClaw ad 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. +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. Normal NemoClaw Anthropic inference still routes through the OpenShell gateway. @@ -279,7 +281,7 @@ To replace the live policy with a complete base policy file, export the current Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. ```bash -$$nemoclaw policy-get > current-policy.yaml +$$nemoclaw policy get > current-policy.yaml ``` NemoClaw strips the OpenShell metadata header and exits non-zero if it cannot validate the base policy. @@ -290,3 +292,62 @@ Edit or review `current-policy.yaml`, then apply it: ```bash openshell policy set --policy current-policy.yaml --wait ``` + +### Excluding a baseline entry + +The baseline policy applies to every sandbox, but an operator can persistently exclude a specific baseline entry when they accept reduced, unsupported functionality in exchange for tighter egress. + +Preview the exact scope, then exclude an exact baseline key: + +```bash +$$nemoclaw policy exclude --dry-run +$$nemoclaw policy exclude --force +``` + +The command prints every endpoint, method/path rule, and binary the exclusion removes. +It also names the supported features that may stop working before it requires explicit acknowledgement (`--force` in non-interactive use). +NemoClaw refuses to exclude an entry that does not have a reviewed feature-impact disclosure, so a new baseline entry cannot bypass this review. +The versioned exclusion record is bound to the reviewed entry content and active agent, recorded in the sandbox registry, and replayed on rebuild. +If the sandbox's active agent changes, NemoClaw requires you to clear or review the exclusion again instead of applying an approval from another agent baseline. +NemoClaw journals the cross-system update before changing the live OpenShell policy and verifies the exact live result before publishing the durable intent. +If the process or persistence layer fails between those steps, `policy list`, `policy explain`, and `status` report `repair required`. +Sandbox creation or recreation, rebuild, and cross-sandbox snapshot cloning stop before destructive work, and rerunning the same `policy exclude` or `policy restore` command reconciles only an exact source or target state. +An unreadable live policy, or one that matches neither the pre-mutation source nor the intended target, remains fail-closed for manual inspection instead of being guessed or overwritten. +An interrupted restore finalizes only when the durable exclusion still exactly matches the staged exclusion and the current release baseline still exactly matches the journaled target; if either changes or becomes unreadable, the journal remains pending for inspection and re-review. +If a later release changes that entry, create and rebuild fail closed until re-review; if the release removes it, they fail closed until the stale record is cleared. +The critical `managed_inference` entry cannot be excluded because it carries the required route to managed inference. +NemoClaw enforces this both when the command runs and whenever durable exclusion state is replayed. + +An excluded baseline key remains reserved while the exclusion is active. +NemoClaw refuses onboarding, rebuild, built-in and custom presets, messaging channel policies, and generated MCP policies that would define the same key and silently restore its egress. +It also refuses to exclude a key that an applied preset already owns. +Restore the baseline entry before applying a preset that intentionally owns the key, or rename a custom entry whose key represents different access. + +To recover from baseline drift, first check whether the release changed the entry's content or removed it entirely (`$$nemoclaw policy explain` or `doctor` reports which). + +If the entry still exists with different content, clear the stale record, review the current scope, and explicitly approve it again only if you still accept the impact: + +```bash +$$nemoclaw policy restore +$$nemoclaw policy exclude --dry-run +$$nemoclaw policy exclude --force +``` + +If the release removed the entry entirely, `policy exclude ` fails with "Unknown baseline entry" because there is nothing left to exclude. +Clear the stale record instead: + +```bash +$$nemoclaw policy restore +``` + +List active exclusions with `policy list`. +`policy explain`, `status`, `doctor`, and snapshot and rebuild summaries also disclose active exclusions and their reduced-support impact. +Status and doctor compare each approval with the active agent baseline and verify that the excluded key is absent from the live OpenShell policy. +An unreadable live policy is unverified, while a live policy that contains the key is a mismatch that requires repair before you rely on the exclusion. +Restore an entry against the current baseline and clear its exclusion: + +```bash +$$nemoclaw policy restore +``` + +Excluding a baseline entry leaves agent features that depend on it unsupported for that sandbox. diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index aa8a407c71e..6e126e90f32 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -132,7 +132,7 @@ Each row below is a launch-facing capability claim that NemoClaw makes in docs, | Guided onboarding | Tested | Single-command interactive wizard (`$$nemoclaw onboard`) that walks the user through inference provider selection, credential setup, sandbox creation, and dashboard launch. Non-interactive mode is supported with `--non-interactive` + `NEMOCLAW_*` environment variables for CI and scripted installs. | | Sandboxed execution | Tested with limitations | Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (ulimit -u 512 at `scripts/lib/sandbox-init.sh:237`) are tested and on by default. The `DANGEROUS_CAPS` list at `scripts/lib/sandbox-init.sh:288-299` drops cap_sys_admin, cap_sys_ptrace, cap_net_raw, cap_dac_override, cap_sys_chroot, cap_fsetid, cap_setfcap, cap_mknod, cap_audit_write, cap_net_bind_service with `capsh --drop` when CAP_SETPCAP is present. Limitation (active issue #3280): the fail-closed bounding-set gate is opt-in via `NEMOCLAW_REQUIRE_CAP_DROP=1`; the default is warn-and-continue so hosts without CAP_SETPCAP still boot, which means dangerous caps can remain in the bounding set on some hosts even though the effective set is empty. | | Routed inference | Tested | Provider-routed model calls through the OpenShell gateway, transparent to the agent. The agent uses `inference.local` inside the sandbox; provider credentials stay on the host. Supports every entry in the Providers table. | -| Declarative network policy | Tested | YAML-defined egress with policy presets. Presets include `slack`, `discord`, `telegram`, `weather`, `openclaw-pricing`, `huggingface`, `npm`, `pypi`, `brew`, and others. Hot-reloadable at runtime with `$$nemoclaw policy-add`. | +| Declarative network policy | Tested | YAML-defined egress with policy presets. Presets include `slack`, `discord`, `telegram`, `weather`, `openclaw-pricing`, `huggingface`, `npm`, `pypi`, `brew`, and others. Hot-reloadable at runtime with `$$nemoclaw policy add`. | | Snapshot and restore | Tested | Create, list, and restore named snapshots of sandbox state with the `$$nemoclaw snapshot` subcommands (`create`, `list`, `restore`). Credential stripping is enforced on capture. Unsafe symlinks are rejected on restore. | | Agent skills | Tested | Packaged agent skills are discoverable by Cursor, Claude Code, and other coding assistants under `.agents/skills/`. Skills also install into the sandbox with `$$nemoclaw skill install`. | | State migration | Tested | Sandbox state migrates across rebuilds with credentials intentionally excluded. Hermes excludes `auth.json` and restores its SQLite session DB through the backup API. OpenClaw config merge prevents stale state from overwriting fresh values. | diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 2b8e8aab98d..3dce945de21 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -489,7 +489,7 @@ First confirm that the provider credential and matching policy preset exist. ```bash $$nemoclaw credentials list -$$nemoclaw policy-list +$$nemoclaw policy list ``` Look for `-brave-search` with the `brave` preset or `-tavily-search` with the `tavily` preset. @@ -1200,13 +1200,13 @@ The Tavily flow only opens Python egress for project code or manually configured Confirm that the target sandbox has the `tavily` preset applied: ```bash -nemo-deepagents policy-list +nemo-deepagents policy list ``` If it is missing, apply the preset, register the host-side credential, and rebuild so the provider attaches: ```bash -nemo-deepagents policy-add tavily --yes +nemo-deepagents policy add tavily --yes export TAVILY_API_KEY=tvly-... nemo-deepagents credentials add tavily-search --type tavily --credential TAVILY_API_KEY unset TAVILY_API_KEY @@ -1298,8 +1298,8 @@ curl: (56) CONNECT tunnel failed, response 403 $$nemoclaw: recent network policy denial detected for example.com:443 inside sandbox 'oc-fresh'. The sandbox's egress policy blocked this request; the tool above only saw the proxy's 403. See the denied flow: $$nemoclaw oc-fresh logs --tail 50 - Review applied presets: $$nemoclaw oc-fresh policy-list - Allow the host: $$nemoclaw oc-fresh policy-add + Review applied presets: $$nemoclaw oc-fresh policy list + Allow the host: $$nemoclaw oc-fresh policy add Silence this hint: export NEMOCLAW_NO_POLICY_HINT=1 ``` @@ -1334,18 +1334,19 @@ If the host should be reachable, allow it with a built-in preset or apply a revi ```bash -$$nemoclaw policy-add +$$nemoclaw policy add ``` ```bash -nemo-deepagents policy-add --from-file ./my-preset.yaml --yes +nemo-deepagents policy add --from-file ./my-preset.yaml --yes ``` -Replace `` with a real preset name such as `github`, `pypi`, or `npm`. Run `$$nemoclaw policy-add` with no preset to list the available presets. +Replace `` with a real preset name such as `github`, `pypi`, or `npm`. +Run `$$nemoclaw policy add` with no preset to list the available presets. ### Sandbox creation reports a TLS certificate mismatch @@ -1979,7 +1980,7 @@ Inside the running sandbox, processes continue to use the OpenShell L7 proxy for ### Agent cannot reach a host-side HTTP service When a sandbox needs to call an HTTP service running on the host, use the normal OpenShell network policy path. -Expose the service on a host IP address that the OpenShell gateway can reach, create a custom NemoClaw policy preset for that IP and port, and apply it with `$$nemoclaw policy-add --from-file`. +Expose the service on a host IP address that the OpenShell gateway can reach, create a custom NemoClaw policy preset for that IP and port, and apply it with `$$nemoclaw policy add --from-file`. The sandbox request then flows through the OpenShell proxy while NemoClaw preserves the existing live policy entries. Do not rely on `host.docker.internal` or `host.openshell.internal` as a general-purpose host-service path. @@ -2023,7 +2024,7 @@ network_policies: Apply the preset to the running sandbox with the NemoClaw CLI: ```bash -$$nemoclaw my-assistant policy-add --from-file ./host-memory-api.yaml +$$nemoclaw my-assistant policy add --from-file ./host-memory-api.yaml ``` After you apply the policy, retry the request from inside the sandbox without disabling the proxy: @@ -2056,7 +2057,7 @@ To permanently allow an endpoint, add it to the network policy. Refer to [Customize the Network Policy](../network-policy/customize-network-policy) for details. -For Deep Agents, use `nemo-deepagents policy-add ` for built-in presets or `nemo-deepagents policy-add --from-file ` for reviewed custom presets. +For Deep Agents, use `nemo-deepagents policy add ` for built-in presets or `nemo-deepagents policy add --from-file ` for reviewed custom presets. diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index d4a2f2c1536..a1ae1731cd2 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -57,6 +57,7 @@ Revalidated on 2026-07-21: the command exited `0` under Node `v22.22.2`. This runtime satisfies the OpenClaw engine requirement of `>=22.19.0`. The remediated reviewed-archive graph reported `0` info, `1` low, `12` moderate, `0` high, and `0` critical findings across `767` total dependencies. The mcporter locked graph reported no findings across `138` dependencies. +The locked mcporter graph overrides `@hono/node-server` to patched release `2.0.11` because `@modelcontextprotocol/sdk@1.29.0` still requests a v1 range that contains no patched release for `GHSA-frvp-7c67-39w9`. The configured `high` threshold therefore passed. The retained low finding is `GHSA-v422-hmwv-36x6` in `body-parser@2.0.0` through `2.2.2`. diff --git a/package-lock.json b/package-lock.json index c0b3f5da283..87c1f267885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1046.0", "@oclif/core": "^4.10.5", + "ajv": "^8.17.0", "execa": "^9.6.1", "js-yaml": "^4.1.1", "p-retry": "^4.6.2", @@ -35,7 +36,6 @@ "@j178/prek": "^0.3.6", "@types/node": "^25.5.2", "@vitest/coverage-v8": "^4.1.0", - "ajv": "^8.17.0", "fast-check": "^4.8.0", "tsx": "^4.21.0", "typebox": "1.1.38", @@ -4350,7 +4350,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5117,14 +5116,12 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -5794,7 +5791,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/lightningcss": { @@ -6497,7 +6493,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 59d9c86aba0..371aa600cfd 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1046.0", "@oclif/core": "^4.10.5", + "ajv": "^8.17.0", "execa": "^9.6.1", "js-yaml": "^4.1.1", "p-retry": "^4.6.2", @@ -107,6 +108,7 @@ "nemoclaw/openclaw.plugin.json", "nemoclaw/package.json", "nemoclaw-blueprint/", + "schemas/sandbox-policy.schema.json", "scripts/", "docs/resources/local-credential-form.html", "Dockerfile", @@ -127,7 +129,6 @@ "@j178/prek": "^0.3.6", "@types/node": "^25.5.2", "@vitest/coverage-v8": "^4.1.0", - "ajv": "^8.17.0", "fast-check": "^4.8.0", "tsx": "^4.21.0", "typebox": "1.1.38", diff --git a/scripts/checks/openshell-policy-mutation-read.mts b/scripts/checks/openshell-policy-mutation-read.mts index 57b9a9dbb4f..133a981dc62 100644 --- a/scripts/checks/openshell-policy-mutation-read.mts +++ b/scripts/checks/openshell-policy-mutation-read.mts @@ -44,7 +44,7 @@ export const MUTATION_READS: readonly AuditedMutationRead[] = [ }, { relativePath: "src/lib/policy/index.ts", - expectedReadCalls: 6, + expectedReadCalls: 7, baseCommand: "runCapture(buildPolicyGetCommand(sandboxName))", unsafeBaseCommand: "runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true })", fullCommand: "runCapture(buildPolicyGetFullCommand(sandboxName), { ignoreError: true })", diff --git a/src/commands/sandbox/policy/exclude.ts b/src/commands/sandbox/policy/exclude.ts new file mode 100644 index 00000000000..ac6dc33c01d --- /dev/null +++ b/src/commands/sandbox/policy/exclude.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { excludeSandboxBaseline } from "../../../lib/actions/sandbox/policy-channel"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import { + commonPolicyOptions, + policyBaselineArgs, + policyMutationFlags, +} from "../../../lib/sandbox/policy-command-support"; + +export default class PolicyExcludeCommand extends NemoClawCommand { + static id = "sandbox:policy:exclude"; + static strict = true; + static summary = "Exclude an entry from the agent baseline policy"; + static description = + "Persistently exclude an exact baseline network policy entry from a sandbox. The removed egress and its support impact are previewed before mutation, and the exclusion is replayed across rebuild."; + static usage = [" [--force|-f] [--yes|-y] [--dry-run]"]; + static examples = [ + "<%= config.bin %> sandbox policy exclude alpha nous_research --force", + "<%= config.bin %> sandbox policy exclude alpha nous_research --dry-run", + ]; + static args = policyBaselineArgs; + static flags = policyMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(PolicyExcludeCommand); + await excludeSandboxBaseline(args.sandboxName, { + key: args.key, + ...commonPolicyOptions(flags), + }); + } +} diff --git a/src/commands/sandbox/policy/mutate.test.ts b/src/commands/sandbox/policy/mutate.test.ts index 7446a1fc12d..0eed1116d21 100644 --- a/src/commands/sandbox/policy/mutate.test.ts +++ b/src/commands/sandbox/policy/mutate.test.ts @@ -6,12 +6,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ addSandboxPolicy: vi.fn().mockResolvedValue(undefined), removeSandboxPolicy: vi.fn().mockResolvedValue(undefined), + excludeSandboxBaseline: vi.fn().mockResolvedValue(undefined), + restoreSandboxBaseline: vi.fn().mockResolvedValue(undefined), })); vi.mock("../../../lib/actions/sandbox/policy-channel", () => mocks); import PolicyAddCommand from "./add"; +import PolicyExcludeCommand from "./exclude"; import PolicyRemoveCommand from "./remove"; +import PolicyRestoreCommand from "./restore"; const rootDir = process.cwd(); @@ -65,4 +69,32 @@ describe("policy mutation oclif commands", () => { expect(mocks.addSandboxPolicy).not.toHaveBeenCalled(); }); + + it("maps policy-exclude args to typed baseline options", async () => { + await PolicyExcludeCommand.run(["alpha", "nous_research", "--force"], rootDir); + + expect(mocks.excludeSandboxBaseline).toHaveBeenCalledWith("alpha", { + key: "nous_research", + yes: false, + force: true, + dryRun: false, + }); + }); + + it("maps policy-restore args to typed baseline options", async () => { + await PolicyRestoreCommand.run(["alpha", "nous_research", "--dry-run"], rootDir); + + expect(mocks.restoreSandboxBaseline).toHaveBeenCalledWith("alpha", { + key: "nous_research", + yes: false, + force: false, + dryRun: true, + }); + }); + + it("requires an explicit baseline key before dispatch", async () => { + await expect(PolicyExcludeCommand.run(["alpha"], rootDir)).rejects.toThrow(); + + expect(mocks.excludeSandboxBaseline).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/sandbox/policy/restore.ts b/src/commands/sandbox/policy/restore.ts new file mode 100644 index 00000000000..0a9a426cdc6 --- /dev/null +++ b/src/commands/sandbox/policy/restore.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { restoreSandboxBaseline } from "../../../lib/actions/sandbox/policy-channel"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +import { + commonPolicyOptions, + policyBaselineArgs, + policyBaselineRestoreFlags, +} from "../../../lib/sandbox/policy-command-support"; + +export default class PolicyRestoreCommand extends NemoClawCommand { + static id = "sandbox:policy:restore"; + static strict = true; + static summary = "Restore a previously excluded baseline entry"; + static description = + "Restore an excluded baseline network policy entry against the current release baseline and drop its recorded exclusion."; + static usage = [" [--dry-run]"]; + static examples = [ + "<%= config.bin %> sandbox policy restore alpha nous_research", + "<%= config.bin %> sandbox policy restore alpha nous_research --dry-run", + ]; + static args = policyBaselineArgs; + static flags = policyBaselineRestoreFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(PolicyRestoreCommand); + await restoreSandboxBaseline(args.sandboxName, { + key: args.key, + ...commonPolicyOptions(flags), + }); + } +} diff --git a/src/lib/actions/root-help.ts b/src/lib/actions/root-help.ts index 52b8672ce23..18e5b5aeb43 100644 --- a/src/lib/actions/root-help.ts +++ b/src/lib/actions/root-help.ts @@ -92,7 +92,7 @@ export function help(): void { ` ${D}• Check inference route: ${CLI_NAME} inference get${R}`, ` ${D}• Change inference model: ${CLI_NAME} inference set --model --provider ${R}`, ); - lines.push(` ${D}• Add network presets: use the policy-add command on your sandbox${R}`); + lines.push(` ${D}• Add network presets: use the policy add command on your sandbox${R}`); lines.push( ` ${D}• Change credentials: credentials reset , then re-run onboard${R}`, ); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 0edfb8e69d6..e7e91e81803 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -267,7 +267,7 @@ function buildBasicChannelReport( : `${policyLabel} preset not applied`, hint: presetInRegistry ? undefined - : `run \`${CLI_NAME} ${sandboxName} policy-add ${policyPresets[0]}\``, + : `run \`${CLI_NAME} ${sandboxName} policy add ${policyPresets[0]}\``, }); if (enabled) { signals.push(...buildConfigStatusSignals(sandboxName, channelName, entry, agent, deps)); diff --git a/src/lib/actions/sandbox/dcode-probe-test-fixture.ts b/src/lib/actions/sandbox/dcode-probe-test-fixture.ts new file mode 100644 index 00000000000..79dd6e74e9a --- /dev/null +++ b/src/lib/actions/sandbox/dcode-probe-test-fixture.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SANDBOX_EXEC_STARTED_MARKER } from "./sandbox-exec-output"; + +export type DcodeProbeState = "active" | "idle" | "unverifiable" | "no-runtime"; + +export function dcodeProbeOutput(state: DcodeProbeState, extra = ""): string { + return `${SANDBOX_EXEC_STARTED_MARKER}\nNEMOCLAW_DCODE_PROBE=${state}\n${extra}`; +} + +export function framedDcodeProbeOutput(state: DcodeProbeState, framePrefix = "stdout: "): string { + return `${framePrefix}${SANDBOX_EXEC_STARTED_MARKER}\n${framePrefix}NEMOCLAW_DCODE_PROBE=${state}\n`; +} diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index d945da5c7c0..b4b77424a8e 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -19,6 +19,9 @@ function createDoctorHarness(): { captureHostCommandSpy: MockInstance; configuredMessagingChannelsSpy: MockInstance; executeSandboxCommandForVerificationSpy: MockInstance; + getBaselineExclusionsSpy: MockInstance; + getBaselineExclusionTransitionSpy: MockInstance; + getBaselineExclusionRuntimeStatusSpy: MockInstance; getSandboxSpy: MockInstance; getNamedGatewayLifecycleStateSpy: MockInstance; healthProbeSpy: MockInstance; @@ -46,6 +49,7 @@ function createDoctorHarness(): { const health = requireDist("../../inference/health.js"); const dockerDriverPlatform = requireDist("../../onboard/docker-driver-platform.js"); const gatewayBinding = requireDist("../../onboard/gateway-binding.js"); + const policy = requireDist("../../policy/index.js"); const sandboxVerificationExec = requireDist("../../onboard/sandbox-verification-exec.js"); const sandboxVersion = requireDist("../../sandbox/version.js"); const shields = requireDist("../../shields/index.js"); @@ -75,6 +79,13 @@ function createDoctorHarness(): { .spyOn(registry, "getConfiguredMessagingChannelsFromEntry") .mockReturnValue([]); vi.spyOn(registry, "getDisabledMessagingChannelsFromEntry").mockReturnValue([]); + const getBaselineExclusionsSpy = vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([]); + const getBaselineExclusionTransitionSpy = vi + .spyOn(registry, "getBaselineExclusionTransition") + .mockReturnValue(null); + const getBaselineExclusionRuntimeStatusSpy = vi + .spyOn(policy, "getBaselineExclusionRuntimeStatus") + .mockReturnValue("excluded"); const resolveOpenShellSpy = vi .spyOn(resolve, "resolveOpenshell") .mockReturnValue("/usr/bin/openshell"); @@ -205,6 +216,9 @@ function createDoctorHarness(): { captureHostCommandSpy, configuredMessagingChannelsSpy, executeSandboxCommandForVerificationSpy, + getBaselineExclusionsSpy, + getBaselineExclusionTransitionSpy, + getBaselineExclusionRuntimeStatusSpy, getSandboxSpy, getNamedGatewayLifecycleStateSpy, healthProbeSpy, @@ -281,6 +295,152 @@ describe("runSandboxDoctor flow", () => { }, ); + it( + "reports baseline exclusions and flags content drift since approval (#7194)", + testTimeoutOptions(30_000), + async () => { + const harness = createDoctorHarness(); + harness.getBaselineExclusionsSpy.mockReturnValue([ + { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "digest-1", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + { + version: 1, + agent: "openclaw", + key: "changed_entry", + digest: "digest-stale", + acknowledgedAt: "2026-07-18T00:00:00.000Z", + }, + { + version: 1, + agent: "openclaw", + key: "dropped_entry", + digest: "digest-2", + acknowledgedAt: "2026-07-17T00:00:00.000Z", + }, + ]); + const statuses: Record = { + nous_research: "excluded", + changed_entry: "content-changed", + dropped_entry: "no-longer-in-baseline", + }; + harness.getBaselineExclusionRuntimeStatusSpy.mockImplementation( + (_sandbox, entry) => statuses[entry.key], + ); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(report?.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + group: "Sandbox", + label: "Baseline exclusion: nous_research", + status: "info", + }), + expect.objectContaining({ + group: "Sandbox", + label: "Baseline exclusion: changed_entry", + status: "warn", + hint: expect.stringContaining("policy restore changed_entry"), + }), + expect.objectContaining({ + group: "Sandbox", + label: "Baseline exclusion: dropped_entry", + status: "warn", + detail: + "Baseline entry 'dropped_entry' no longer exists; rebuild fails closed until the stale exclusion is cleared.", + hint: "key no longer exists in the baseline; run `nemoclaw alpha policy restore dropped_entry` to clear the stale record", + }), + ]), + ); + }, + ); + + it("fails when registry intent is not enforced by the live policy (#7194)", async () => { + const harness = createDoctorHarness(); + harness.getBaselineExclusionsSpy.mockReturnValue([ + { + version: 1, + agent: "hermes", + key: "pypi", + digest: "a".repeat(64), + }, + ]); + harness.getBaselineExclusionRuntimeStatusSpy.mockReturnValue("live-policy-mismatch"); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(report?.checks).toContainEqual( + expect.objectContaining({ + label: "Baseline exclusion: pypi", + status: "fail", + detail: expect.stringContaining("not enforced"), + }), + ); + }); + + it("flags an interrupted baseline transaction as a rebuild-blocking repair (#7178)", async () => { + const harness = createDoctorHarness(); + harness.getBaselineExclusionTransitionSpy.mockReturnValue({ + id: "tx-1", + operation: "restore", + exclusion: { version: 1, agent: "openclaw", key: "nous_research", digest: "approved" }, + targetLiveDigest: "current", + startedAt: "2026-07-19T00:00:00.000Z", + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(report?.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + group: "Sandbox", + label: "Baseline exclusion: nous_research", + status: "warn", + detail: expect.stringContaining("interrupted"), + hint: "re-run `nemoclaw alpha policy restore nous_research`", + }), + ]), + ); + }); + + it("keeps repair guidance visible when another exclusion baseline is unreadable (#7194)", async () => { + const harness = createDoctorHarness(); + harness.getBaselineExclusionsSpy.mockReturnValue([ + { version: 1, agent: "openclaw", key: "another_entry", digest: "c".repeat(64) }, + { version: 1, agent: "openclaw", key: "nous_research", digest: "a".repeat(64) }, + ]); + harness.getBaselineExclusionTransitionSpy.mockReturnValue({ + id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", + operation: "restore", + exclusion: { version: 1, agent: "openclaw", key: "nous_research", digest: "a".repeat(64) }, + targetLiveDigest: "b".repeat(64), + startedAt: "2026-07-19T00:00:00.000Z", + }); + harness.getBaselineExclusionRuntimeStatusSpy.mockReturnValue("baseline-unreadable"); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(report?.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "Baseline exclusion: another_entry", + status: "warn", + detail: expect.stringContaining("unreadable"), + }), + expect.objectContaining({ + label: "Baseline exclusion: nous_research", + status: "warn", + detail: expect.stringContaining("interrupted"), + }), + ]), + ); + }); + it.each([ "openclaw", "hermes", diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index cc660626fad..cf5047349d0 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -19,6 +19,11 @@ import { parseGatewayInference } from "../../inference/config"; import { shouldManageDashboardForAgent } from "../../onboard/dashboard-runtime"; import { resolveGatewayName, resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { executeSandboxCommandForVerification } from "../../onboard/sandbox-verification-exec"; +import { getBaselineExclusionRuntimeStatus } from "../../policy"; +import { + BASELINE_EXCLUSION_SUPPORT_IMPACT, + type BaselineExclusionRuntimeStatus, +} from "../../policy/baseline-exclusion"; import { ROOT } from "../../runner"; import { parseLiveSandboxNames } from "../../runtime-recovery"; import * as sandboxVersion from "../../sandbox/version"; @@ -355,6 +360,86 @@ function shieldsDoctorCheck(sandboxName: string): DoctorCheck { }; } +function baselineExclusionCheckFields( + sandboxName: string, + key: string, + runtimeStatus: BaselineExclusionRuntimeStatus, +): Pick { + const restoreCommand = `${CLI_NAME} ${sandboxName} policy restore ${key}`; + if (runtimeStatus === "excluded") { + return { + status: "info", + detail: `Baseline entry '${key}' excluded. ${BASELINE_EXCLUSION_SUPPORT_IMPACT}`, + hint: `restore with \`${restoreCommand}\``, + }; + } + if (runtimeStatus === "no-longer-in-baseline") { + return { + status: "warn", + detail: `Baseline entry '${key}' no longer exists; rebuild fails closed until the stale exclusion is cleared.`, + hint: `key no longer exists in the baseline; run \`${restoreCommand}\` to clear the stale record`, + }; + } + if (runtimeStatus === "agent-changed") { + return { + status: "warn", + detail: `Baseline exclusion '${key}' belongs to a different agent; rebuild fails closed until the stale approval is cleared.`, + hint: `run \`${restoreCommand}\`, then review and approve the current agent baseline if needed`, + }; + } + if (runtimeStatus === "baseline-unreadable") { + return { + status: "warn", + detail: "Current agent baseline is unreadable; exclusion scope could not be verified.", + hint: `inspect \`${CLI_NAME} ${sandboxName} policy list\` before rebuilding`, + }; + } + if (runtimeStatus === "live-policy-unreadable") { + return { + status: "warn", + detail: `Live policy for '${key}' is unreadable; exclusion enforcement could not be verified.`, + hint: `restore gateway access, then rerun \`${CLI_NAME} ${sandboxName} doctor\``, + }; + } + if (runtimeStatus === "live-policy-mismatch") { + return { + status: "fail", + detail: `Live policy still contains excluded baseline entry '${key}'; the recorded exclusion is not enforced.`, + hint: `inspect \`${CLI_NAME} ${sandboxName} policy list\`, remove the colliding source, then re-run the exclusion`, + }; + } + return { + status: "warn", + detail: `Baseline entry '${key}' changed since exclusion was approved; rebuild fails closed until re-approved.`, + hint: `run \`${restoreCommand}\`, review with \`${CLI_NAME} ${sandboxName} policy exclude ${key} --dry-run\`, then re-approve`, + }; +} + +function baselineExclusionDoctorChecks(sandboxName: string): DoctorCheck[] { + const transition = registry.getBaselineExclusionTransition(sandboxName); + const checks: DoctorCheck[] = []; + for (const exclusion of registry.getBaselineExclusions(sandboxName)) { + if (transition?.exclusion.key === exclusion.key) continue; + const runtimeStatus = getBaselineExclusionRuntimeStatus(sandboxName, exclusion); + checks.push({ + group: "Sandbox", + label: `Baseline exclusion: ${exclusion.key}`, + ...baselineExclusionCheckFields(sandboxName, exclusion.key, runtimeStatus), + }); + } + if (transition) { + const key = transition.exclusion.key; + checks.push({ + group: "Sandbox", + label: `Baseline exclusion: ${key}`, + status: "warn", + detail: `Baseline policy ${transition.operation} for '${key}' was interrupted; rebuild is blocked until live and durable state are reconciled.`, + hint: `re-run \`${CLI_NAME} ${sandboxName} policy ${transition.operation} ${key}\``, + }); + } + return checks; +} + function collectRegisteredSandboxChecks( sandboxName: string, sb: SandboxEntry | null | undefined, @@ -379,6 +464,7 @@ function collectRegisteredSandboxChecks( }); if (permsCheck) checks.push(permsCheck); checks.push(...collectMessagingDoctorChecks(sandboxName, sb, sandboxReachable)); + checks.push(...baselineExclusionDoctorChecks(sandboxName)); return checks; } diff --git a/src/lib/actions/sandbox/exec-policy-hint-rendering.test.ts b/src/lib/actions/sandbox/exec-policy-hint-rendering.test.ts index be982a0c7ef..a5ff1a3b62d 100644 --- a/src/lib/actions/sandbox/exec-policy-hint-rendering.test.ts +++ b/src/lib/actions/sandbox/exec-policy-hint-rendering.test.ts @@ -11,8 +11,8 @@ describe("buildPolicyDenialExecHint (#5978)", () => { ["the denied endpoint", "example.com:443"], ["the sandbox name", "oc-fresh"], ["the logs breadcrumb", "nemoclaw oc-fresh logs --tail 50"], - ["the policy-list review breadcrumb", "nemoclaw oc-fresh policy-list"], - ["the policy-add allow-path breadcrumb", "nemoclaw oc-fresh policy-add "], + ["the policy-list review breadcrumb", "nemoclaw oc-fresh policy list"], + ["the policy-add allow-path breadcrumb", "nemoclaw oc-fresh policy add "], ["the opt-out env", POLICY_HINT_SUPPRESS_ENV], ])("names %s", (_label, expected) => { expect(hint).toContain(expected); @@ -52,7 +52,7 @@ describe("buildPolicyDenialExecHint (#5978)", () => { ])("renders the placeholder for an unsafe sandbox name: %s", (_label, unsafe) => { const hint = buildPolicyDenialExecHint("nemoclaw", unsafe, "example.com:443"); expect(hint).toContain("nemoclaw logs --tail 50"); - expect(hint).toContain("nemoclaw policy-add "); + expect(hint).toContain("nemoclaw policy add "); expect(hint).not.toContain(unsafe); expect(hint).not.toContain(""); }); diff --git a/src/lib/actions/sandbox/exec-policy-hint-rendering.ts b/src/lib/actions/sandbox/exec-policy-hint-rendering.ts index ccef17d01ac..10d1e3e47ac 100644 --- a/src/lib/actions/sandbox/exec-policy-hint-rendering.ts +++ b/src/lib/actions/sandbox/exec-policy-hint-rendering.ts @@ -23,8 +23,8 @@ export function buildPolicyDenialExecHint( `${cliName}: recent network policy denial detected${target} inside sandbox '${sandboxName}'.`, " The sandbox's egress policy blocked this request; the tool above only saw the proxy's 403.", ` See the denied flow: ${cliName} ${sandboxName} logs --tail 50`, - ` Review applied presets: ${cliName} ${sandboxName} policy-list`, - ` Allow the host: ${cliName} ${sandboxName} policy-add `, + ` Review applied presets: ${cliName} ${sandboxName} policy list`, + ` Allow the host: ${cliName} ${sandboxName} policy add `, ` Silence this hint: export ${POLICY_HINT_SUPPRESS_ENV}=1`, ].join("\n"); } diff --git a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts index 6f157aacf26..8d386643c87 100644 --- a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts +++ b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts @@ -194,7 +194,7 @@ describe("addSandboxPolicy drift-aware named re-add", () => { " Preset 'pypi' was applied as a custom preset (--from-file).", ); expect(errSpy).toHaveBeenCalledWith( - ` Edit and re-apply it with --from-file, or run '${CLI_NAME} alpha policy-remove pypi' first.`, + ` Edit and re-apply it with --from-file, or run '${CLI_NAME} alpha policy remove pypi' first.`, ); expect(gatewayStateMock).not.toHaveBeenCalled(); expect(applyPresetMock).not.toHaveBeenCalled(); @@ -226,7 +226,7 @@ describe("addSandboxPolicy drift-aware named re-add", () => { expect(errSpy).toHaveBeenCalledWith( " Could not read the live sandbox policy to compare (is the sandbox gateway running?).", ); - expect(errSpy).not.toHaveBeenCalledWith(expect.stringContaining("policy-remove")); + expect(errSpy).not.toHaveBeenCalledWith(expect.stringContaining("policy remove")); expect(applyPresetMock).not.toHaveBeenCalled(); expect(refreshSpy).not.toHaveBeenCalled(); }); diff --git a/src/lib/actions/sandbox/policy-channel-baseline.test.ts b/src/lib/actions/sandbox/policy-channel-baseline.test.ts new file mode 100644 index 00000000000..6d2bba67c24 --- /dev/null +++ b/src/lib/actions/sandbox/policy-channel-baseline.test.ts @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +import * as store from "../../credentials/store"; +import * as policies from "../../policy"; +import type { PolicyObject } from "../../policy/preset-parsing"; +import * as registry from "../../state/registry"; + +vi.mock("../../state/mcp-lifecycle-lock", () => ({ + withSandboxMutationLock: (_name: string, action: () => Promise) => action(), +})); +vi.mock("./policy-context-refresh", () => ({ + refreshSandboxPolicyContextFile: vi.fn(), +})); + +import { excludeSandboxBaseline, restoreSandboxBaseline } from "./policy-channel"; + +class ExitError extends Error { + constructor(public readonly code: number | undefined) { + super(`process.exit(${code})`); + } +} + +const BASE_CONTENT = `version: 1 +network_policies: + nous_research: + name: nous_research + endpoints: + - host: nousresearch.com + port: 443 + rules: + - allow: { method: GET, path: "/**" } + managed_inference: + name: managed_inference + endpoints: + - host: inference.local +`; + +const NOUS_ENTRY: PolicyObject = { + name: "nous_research", + endpoints: [{ host: "nousresearch.com", port: 443 }], +}; + +let exitSpy: MockInstance; +let promptMock: MockInstance; +let excludeBaselineEntryMock: MockInstance; +let restoreBaselineEntryMock: MockInstance; +let getBaselineExclusionsMock: MockInstance; + +async function captureExit(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + expect(error).toBeInstanceOf(ExitError); + return (error as ExitError).code; + } + throw new Error("Expected process.exit to be called"); +} + +beforeEach(() => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code); + }) as never); + promptMock = vi.spyOn(store, "prompt").mockResolvedValue("y"); + + vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: "hermes" }); + getBaselineExclusionsMock = vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([]); + vi.spyOn(registry, "getBaselineExclusionTransition").mockReturnValue(null); + + vi.spyOn(policies, "resolveSandboxBaselinePolicy").mockReturnValue({ + agent: "hermes", + policyPath: "/repo/policy-additions.yaml", + content: BASE_CONTENT, + }); + vi.spyOn(policies, "getSandboxBaselineEntry").mockImplementation((_sandbox, key) => + key === "nous_research" ? NOUS_ENTRY : null, + ); + vi.spyOn(policies, "getSandboxBaselineEntryDigest").mockReturnValue("digest-1"); + excludeBaselineEntryMock = vi.spyOn(policies, "excludeBaselineEntry").mockReturnValue(true); + restoreBaselineEntryMock = vi.spyOn(policies, "restoreBaselineEntry").mockReturnValue(true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete process.env.NEMOCLAW_NON_INTERACTIVE; +}); + +describe("excludeSandboxBaseline (#7178)", () => { + it("does not mutate when a recorded agent baseline cannot be resolved (#7194)", async () => { + vi.mocked(policies.resolveSandboxBaselinePolicy).mockImplementation(() => { + throw new Error("Refusing to substitute the OpenClaw baseline"); + }); + + await expect( + excludeSandboxBaseline("alpha", { key: "nous_research", force: true }), + ).rejects.toThrow("Refusing to substitute the OpenClaw baseline"); + + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); + + it("exits on an unknown baseline key without mutating", async () => { + const code = await captureExit(() => + excludeSandboxBaseline("alpha", { key: "absent", force: true }), + ); + expect(code).toBe(1); + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); + + it("refuses to exclude a protected baseline entry", async () => { + vi.spyOn(policies, "getSandboxBaselineEntry").mockReturnValue({ name: "managed_inference" }); + const code = await captureExit(() => + excludeSandboxBaseline("alpha", { key: "managed_inference", force: true }), + ); + expect(code).toBe(1); + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); + + it("requires explicit acknowledgement in non-interactive mode", async () => { + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + const code = await captureExit(() => excludeSandboxBaseline("alpha", { key: "nous_research" })); + expect(code).toBe(1); + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); + + it("excludes with a bound digest when acknowledged via --force", async () => { + await excludeSandboxBaseline("alpha", { key: "nous_research", force: true }); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining( + "Support impact: Hermes public metadata lookup and agent updates may stop working.", + ), + ); + expect(promptMock).not.toHaveBeenCalled(); + expect(excludeBaselineEntryMock).toHaveBeenCalledWith( + "alpha", + "nous_research", + expect.any(String), + ); + }); + + it("discloses the affected feature before interactive acknowledgement", async () => { + promptMock.mockImplementation(async () => { + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining( + "Support impact: Hermes public metadata lookup and agent updates may stop working.", + ), + ); + return "n"; + }); + + await excludeSandboxBaseline("alpha", { key: "nous_research" }); + + expect(promptMock).toHaveBeenCalledOnce(); + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); + + it("fails closed when an entry has no reviewed feature disclosure", async () => { + vi.spyOn(policies, "getSandboxBaselineEntry").mockReturnValue({ name: "future_entry" }); + const code = await captureExit(() => + excludeSandboxBaseline("alpha", { key: "future_entry", force: true }), + ); + + expect(code).toBe(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("has no supported-feature impact disclosure"), + ); + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); + + it("does not mutate on --dry-run", async () => { + await excludeSandboxBaseline("alpha", { key: "nous_research", dryRun: true }); + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); + + it("aborts when the interactive confirmation is declined", async () => { + promptMock.mockResolvedValue("n"); + await excludeSandboxBaseline("alpha", { key: "nous_research" }); + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); +}); + +describe("restoreSandboxBaseline (#7178)", () => { + it("exits when the key is not excluded", async () => { + getBaselineExclusionsMock.mockReturnValue([]); + const code = await captureExit(() => restoreSandboxBaseline("alpha", { key: "nous_research" })); + expect(code).toBe(1); + expect(restoreBaselineEntryMock).not.toHaveBeenCalled(); + }); + + it("restores a recorded exclusion", async () => { + getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); + await restoreSandboxBaseline("alpha", { key: "nous_research" }); + expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research"); + }); + + it("does not mutate when a recorded agent baseline cannot be resolved (#7194)", async () => { + getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); + vi.mocked(policies.resolveSandboxBaselinePolicy).mockImplementation(() => { + throw new Error("Refusing to substitute the OpenClaw baseline"); + }); + + await expect(restoreSandboxBaseline("alpha", { key: "nous_research" })).rejects.toThrow( + "Refusing to substitute the OpenClaw baseline", + ); + + expect(restoreBaselineEntryMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/policy-channel-list.test.ts b/src/lib/actions/sandbox/policy-channel-list.test.ts index 113367e45e3..40ec52266b3 100644 --- a/src/lib/actions/sandbox/policy-channel-list.test.ts +++ b/src/lib/actions/sandbox/policy-channel-list.test.ts @@ -11,10 +11,13 @@ type PresetInfo = { const moduleMocks = vi.hoisted(() => ({ getSandbox: vi.fn<(sandboxName: string) => Record | null>(), getCustomPolicies: vi.fn<(sandboxName: string) => PresetInfo[]>(), + getBaselineExclusions: vi.fn(), + getBaselineExclusionTransition: vi.fn(), listPresets: vi.fn<(options?: { agent?: string | null }) => PresetInfo[]>(), listCustomPresets: vi.fn<(sandboxName: string) => PresetInfo[]>(), getAppliedPresets: vi.fn<(sandboxName: string) => string[]>(), getGatewayPresets: vi.fn<(sandboxName: string) => string[] | null>(), + getSandboxBaselineEntryDigest: vi.fn(), isDockerRuntimeDown: vi.fn<(sandboxName: string) => boolean>(), printDockerRuntimeDownGuidance: vi.fn(), })); @@ -23,6 +26,8 @@ vi.mock("../../state/registry", async (importOriginal) => ({ ...(await importOriginal()), getSandbox: moduleMocks.getSandbox, getCustomPolicies: moduleMocks.getCustomPolicies, + getBaselineExclusions: moduleMocks.getBaselineExclusions, + getBaselineExclusionTransition: moduleMocks.getBaselineExclusionTransition, })); vi.mock("../../policy", async (importOriginal) => ({ @@ -31,6 +36,7 @@ vi.mock("../../policy", async (importOriginal) => ({ listCustomPresets: moduleMocks.listCustomPresets, getAppliedPresets: moduleMocks.getAppliedPresets, getGatewayPresets: moduleMocks.getGatewayPresets, + getSandboxBaselineEntryDigest: moduleMocks.getSandboxBaselineEntryDigest, })); vi.mock("./gateway-failure-classifier", async (importOriginal) => ({ @@ -87,6 +93,9 @@ beforeEach(() => { moduleMocks.listPresets.mockReturnValue(POLICY_PRESETS); moduleMocks.listCustomPresets.mockReturnValue([]); moduleMocks.isDockerRuntimeDown.mockReturnValue(false); + moduleMocks.getBaselineExclusions.mockReturnValue([]); + moduleMocks.getBaselineExclusionTransition.mockReturnValue(null); + moduleMocks.getSandboxBaselineEntryDigest.mockReturnValue(null); }); afterEach(() => { @@ -94,6 +103,89 @@ afterEach(() => { }); describe("listSandboxPolicies provenance", () => { + it("discloses an interrupted baseline transaction and exact repair command (#7178)", () => { + arrangeListing({ + appliedNames: [], + gatewayNames: [], + tier: null, + agent: "hermes", + }); + moduleMocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-1", + operation: "exclude", + exclusion: { + version: 1, + agent: "hermes", + key: "nous_research", + digest: "approved", + }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:00.000Z", + }); + + listSandboxPolicies("test-sandbox"); + + const output = printedText(); + expect(output).toContain("repair required — interrupted exclude; rebuild blocked"); + expect(output).toContain("nemoclaw test-sandbox policy exclude nous_research"); + }); + + it.each([ + "exclude", + "restore", + ] as const)("keeps an interrupted %s repair visible when the release baseline is unreadable (#7194)", (operation) => { + arrangeListing({ + appliedNames: [], + gatewayNames: [], + tier: null, + agent: "hermes", + }); + moduleMocks.getBaselineExclusions.mockReturnValue([ + { + version: 1, + agent: "hermes", + key: "another_entry", + digest: "c".repeat(64), + acknowledgedAt: "2026-07-18T00:00:00.000Z", + }, + { + version: 1, + agent: "hermes", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + ]); + moduleMocks.getBaselineExclusionTransition.mockReturnValue({ + id: "00000000-0000-4000-8000-000000000001", + operation, + exclusion: { + version: 1, + agent: "hermes", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + targetLiveDigest: operation === "restore" ? "b".repeat(64) : null, + startedAt: "2026-07-19T00:00:00.000Z", + }); + moduleMocks.getSandboxBaselineEntryDigest.mockImplementation(() => { + throw new Error("release baseline unavailable"); + }); + + expect(() => listSandboxPolicies("test-sandbox")).not.toThrow(); + + const output = printedText(); + expect(output).toContain("another_entry (release baseline unreadable — inspection required)"); + expect(output).toContain(`repair required — interrupted ${operation}; rebuild blocked`); + expect(output).toContain(`nemoclaw test-sandbox policy ${operation} nous_research`); + expect(moduleMocks.getSandboxBaselineEntryDigest).toHaveBeenCalledOnce(); + expect(moduleMocks.getSandboxBaselineEntryDigest).toHaveBeenCalledWith( + "test-sandbox", + "another_entry", + ); + }); + it("tags active tier-default presets with their tier provenance (#5774)", () => { arrangeListing({ appliedNames: ["npm", "pypi"], diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 7b680ffde85..c21b66f45ed 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -10,6 +10,7 @@ import { isNonInteractiveEnv } from "../../core/non-interactive"; import { prompt as askPrompt, getCredential } from "../../credentials/store"; import { type PolicyAddOptions, + type PolicyBaselineOptions, type PolicyRemoveOptions, parsePolicyAddOptions, } from "../../domain/policy-channel"; @@ -39,7 +40,15 @@ import { filterSetupPolicyPresetsForAgent } from "../../onboard/agent-policy-pre import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config"; import { getMessagingToken } from "../../onboard/messaging-token"; import * as policies from "../../policy"; +import { + digestBaselineEntry, + getBaselineExclusionFeatureImpact, + isProtectedBaselineExclusionKey, + listBaselineEntryKeys, + renderBaselineEntryScope, +} from "../../policy/baseline-exclusion"; import { formatPolicyListPresetRow } from "../../policy/policy-list-display"; +import type { PolicyObject } from "../../policy/preset-parsing"; import { shellQuote } from "../../runner"; import { type ChannelDef, @@ -224,7 +233,7 @@ async function addSandboxPolicyUnlocked( // policy and double-register the name. console.error(` Preset '${preset.name}' was applied as a custom preset (--from-file).`); console.error( - ` Edit and re-apply it with --from-file, or run '${CLI_NAME} ${sandboxName} policy-remove ${preset.name}' first.`, + ` Edit and re-apply it with --from-file, or run '${CLI_NAME} ${sandboxName} policy remove ${preset.name}' first.`, ); process.exit(1); } @@ -236,7 +245,7 @@ async function addSandboxPolicyUnlocked( const appliedState = policies.getPresetContentGatewayState(sandboxName, appliedContent); if (appliedState === "match") { // The desired state already holds: exit 0 so converging scripts can - // call policy-add idempotently, mirroring how applyPreset treats a + // call `policy add` idempotently, mirroring how applyPreset treats a // byte-identical re-application as a successful no-op. console.log( ` Preset '${preset.name}' is already applied and matches the live policy; nothing to do.`, @@ -263,7 +272,7 @@ async function addSandboxPolicyUnlocked( } answer = preset.name; } else { - const usage = `${CLI_NAME} policy-add [--yes] [--dry-run]`; + const usage = `${CLI_NAME} policy add [--yes] [--dry-run]`; if (isNonInteractive()) { exitPresetNameRequired(usage); } @@ -400,6 +409,48 @@ export function listSandboxPolicies(sandboxName: string) { ); }); + const exclusions = registry.getBaselineExclusions(sandboxName); + const exclusionTransition = registry.getBaselineExclusionTransition(sandboxName); + if (exclusions.length > 0 || exclusionTransition) { + console.log(""); + console.log(" Baseline exclusions (unsupported egress removed):"); + const listed = new Map(exclusions.map((exclusion) => [exclusion.key, exclusion])); + if (exclusionTransition) { + listed.set(exclusionTransition.exclusion.key, exclusionTransition.exclusion); + } + for (const exclusion of listed.values()) { + const isPending = exclusionTransition?.exclusion.key === exclusion.key; + // A repair command must remain visible even if the current agent + // baseline cannot be loaded. Resolving that baseline is part of the + // explicit retry, not a prerequisite for displaying the journal. + let currentDigest: string | null | undefined; + if (isPending) { + currentDigest = null; + } else { + try { + currentDigest = policies.getSandboxBaselineEntryDigest(sandboxName, exclusion.key); + } catch { + currentDigest = undefined; + } + } + const status = isPending + ? `${YW}repair required — interrupted ${exclusionTransition.operation}; rebuild blocked${R}` + : currentDigest === undefined + ? `${YW}release baseline unreadable — inspection required${R}` + : currentDigest === null + ? `${YW}baseline entry removed — restore to clear${R}` + : currentDigest === exclusion.digest + ? "active" + : `${YW}baseline changed — re-review required${R}`; + console.log(` - ${exclusion.key} (${status})`); + if (isPending) { + console.log( + ` Re-run: ${CLI_NAME} ${sandboxName} policy ${exclusionTransition.operation} ${exclusion.key}`, + ); + } + } + } + if (gatewayPresets === null) { console.log(""); // A null gateway result can be a transient Docker daemon outage rather @@ -1420,7 +1471,7 @@ export function removeChannelPresetIfPresent(sandboxName: string, channelName: s ` ${YW}⚠${R} Channel '${channelName}' bridge removed but its policy preset failed to un-apply.`, ); console.error( - ` Run manually after rebuild with: ${CLI_NAME} ${sandboxName} policy-remove ${channelName}`, + ` Run manually after rebuild with: ${CLI_NAME} ${sandboxName} policy remove ${channelName}`, ); } else { syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "remove"); @@ -1430,7 +1481,7 @@ export function removeChannelPresetIfPresent(sandboxName: string, channelName: s const msg = err instanceof Error ? err.message : String(err); console.error(` ${YW}⚠${R} Failed to remove '${channelName}' policy preset: ${msg}`); console.error( - ` Run manually after rebuild with: ${CLI_NAME} ${sandboxName} policy-remove ${channelName}`, + ` Run manually after rebuild with: ${CLI_NAME} ${sandboxName} policy remove ${channelName}`, ); } } @@ -1677,7 +1728,7 @@ async function removeSandboxPolicyUnlocked( } answer = preset.name; } else { - const usage = `${CLI_NAME} policy-remove [--yes] [--dry-run]`; + const usage = `${CLI_NAME} policy remove [--yes] [--dry-run]`; if (isNonInteractive()) { exitPresetNameRequired(usage); } @@ -1724,3 +1775,157 @@ async function removeSandboxPolicyUnlocked( syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "remove"); refreshSandboxPolicyContextFile(sandboxName); } + +function printBaselineEntryScope(prefix: string, key: string, entry: PolicyObject): void { + console.log(prefix); + for (const line of renderBaselineEntryScope(key, entry)) { + console.log(line); + } +} + +export async function excludeSandboxBaseline( + sandboxName: string, + options: PolicyBaselineOptions = {}, +): Promise { + return withSandboxMutationLock(sandboxName, () => + excludeSandboxBaselineUnlocked(sandboxName, options), + ); +} + +async function excludeSandboxBaselineUnlocked( + sandboxName: string, + options: PolicyBaselineOptions, +): Promise { + const dryRun = Boolean(options.dryRun); + const explicitAck = Boolean(options.yes || options.force); + const key = options.key?.trim(); + if (!key) { + console.error(" A baseline key is required."); + console.error(` Usage: ${CLI_NAME} policy exclude [--force] [--dry-run]`); + process.exit(1); + } + + const baseline = policies.resolveSandboxBaselinePolicy(sandboxName); + if (!baseline) { + console.error(` Could not read the baseline policy for sandbox '${sandboxName}'.`); + process.exit(1); + } + + const entry = policies.getSandboxBaselineEntry(sandboxName, key); + if (!entry) { + console.error(` Unknown baseline entry '${key}'.`); + console.error( + ` Valid baseline keys: ${listBaselineEntryKeys(baseline.content).join(", ") || "(none)"}`, + ); + process.exit(1); + } + + if (isProtectedBaselineExclusionKey(key)) { + console.error( + ` Baseline entry '${key}' is required for managed inference and cannot be excluded.`, + ); + process.exit(1); + } + + const featureImpact = getBaselineExclusionFeatureImpact(baseline.agent, key); + if (!featureImpact) { + console.error( + ` Baseline entry '${key}' has no supported-feature impact disclosure and cannot be excluded safely.`, + ); + process.exit(1); + } + + printBaselineEntryScope( + ` Excluding baseline entry '${key}' from '${sandboxName}' removes:`, + key, + entry, + ); + console.log(` ${YW}Support impact: ${featureImpact}${R}`); + + const digest = digestBaselineEntry(entry); + if (dryRun) { + console.log(" --dry-run: no changes applied."); + return; + } + + if (isNonInteractive() && !explicitAck) { + console.error( + " Non-interactive exclusion requires explicit acknowledgement: pass --force (or --yes).", + ); + process.exit(1); + } + if (!explicitAck) { + const confirm = await askPrompt(` Exclude '${key}' from sandbox '${sandboxName}'? [y/N]: `); + if (!confirm.trim().toLowerCase().startsWith("y")) return; + } + + if (!policies.excludeBaselineEntry(sandboxName, key, digest)) { + // A failed cross-system mutation can leave a durable repair journal. Keep + // the in-sandbox context aligned before returning the nonzero result. + refreshSandboxPolicyContextFile(sandboxName); + process.exit(1); + } + console.log(` ${G}✓${R} Excluded baseline entry '${key}' for '${sandboxName}'.`); + refreshSandboxPolicyContextFile(sandboxName); +} + +export async function restoreSandboxBaseline( + sandboxName: string, + options: PolicyBaselineOptions = {}, +): Promise { + return withSandboxMutationLock(sandboxName, () => + restoreSandboxBaselineUnlocked(sandboxName, options), + ); +} + +async function restoreSandboxBaselineUnlocked( + sandboxName: string, + options: PolicyBaselineOptions, +): Promise { + const dryRun = Boolean(options.dryRun); + const key = options.key?.trim(); + if (!key) { + console.error(" A baseline key is required."); + console.error(` Usage: ${CLI_NAME} policy restore [--dry-run]`); + process.exit(1); + } + + const isExcluded = registry.getBaselineExclusions(sandboxName).some((entry) => entry.key === key); + const pendingTransition = registry.getBaselineExclusionTransition(sandboxName); + const isPendingForKey = pendingTransition?.exclusion.key === key; + if (!isExcluded && !isPendingForKey) { + console.error(` Baseline entry '${key}' is not excluded for '${sandboxName}'.`); + process.exit(1); + } + + const baseline = policies.resolveSandboxBaselinePolicy(sandboxName); + if (!baseline) { + console.error(` Could not read the baseline policy for sandbox '${sandboxName}'.`); + process.exit(1); + } + + const entry = policies.getSandboxBaselineEntry(sandboxName, key); + if (entry) { + printBaselineEntryScope( + ` Restoring baseline entry '${key}' for '${sandboxName}' re-allows:`, + key, + entry, + ); + } else { + console.log( + ` ${YW}⚠${R} The current baseline no longer defines '${key}'; clearing the exclusion record only.`, + ); + } + + if (dryRun) { + console.log(" --dry-run: no changes applied."); + return; + } + + if (!policies.restoreBaselineEntry(sandboxName, key)) { + refreshSandboxPolicyContextFile(sandboxName); + process.exit(1); + } + console.log(` ${G}✓${R} Restored baseline entry '${key}' for '${sandboxName}'.`); + refreshSandboxPolicyContextFile(sandboxName); +} diff --git a/src/lib/actions/sandbox/policy-explain.test.ts b/src/lib/actions/sandbox/policy-explain.test.ts index a7cd9851279..d05082bb0c6 100644 --- a/src/lib/actions/sandbox/policy-explain.test.ts +++ b/src/lib/actions/sandbox/policy-explain.test.ts @@ -10,8 +10,8 @@ vi.mock("../../policy/context", () => ({ import type { PolicyContext } from "../../policy/context"; import { - POLICY_CONTEXT_SANDBOX_PATH, explainSandboxPolicy, + POLICY_CONTEXT_SANDBOX_PATH, writePolicyContextToSandbox, } from "./policy-explain"; @@ -21,10 +21,13 @@ function fakeContext(sandboxName: string): PolicyContext { tier: null, activePresets: [], knownUnappliedPresets: [], + baselineExclusions: [], approvalPath: { inspect: `nemoclaw ${sandboxName} policy-list`, add: `nemoclaw ${sandboxName} policy-add `, remove: `nemoclaw ${sandboxName} policy-remove `, + excludeBaseline: `nemoclaw ${sandboxName} policy exclude --dry-run`, + restoreBaseline: `nemoclaw ${sandboxName} policy restore `, documentation: "docs/network-policy/customize-network-policy.mdx", }, supportBoundaries: [], diff --git a/src/lib/actions/sandbox/rebuild-baseline-transition-preflight.test.ts b/src/lib/actions/sandbox/rebuild-baseline-transition-preflight.test.ts new file mode 100644 index 00000000000..165825ac457 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-baseline-transition-preflight.test.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assertMcpDestroyNotPending: vi.fn(), + bail: vi.fn(), + confirmRebuildIntent: vi.fn(), + countActiveSessions: vi.fn(), + getSandbox: vi.fn(), + prepareTargets: vi.fn(), +})); + +vi.mock("../../state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + getSandbox: mocks.getSandbox, +})); + +vi.mock("./mcp-bridge-state", async (importOriginal) => ({ + ...(await importOriginal()), + assertMcpDestroyNotPending: mocks.assertMcpDestroyNotPending, +})); + +vi.mock("./rebuild-preflight-confirmation", async (importOriginal) => ({ + ...(await importOriginal()), + confirmRebuildIntent: mocks.confirmRebuildIntent, + countActiveSandboxSessionsForRebuild: mocks.countActiveSessions, + createRebuildCommandContext: vi.fn(() => ({ + bail: mocks.bail, + log: vi.fn(), + requestedToolDisclosure: undefined, + requestedDcodeAutoApprovalMode: undefined, + requestedObservabilityEnabled: undefined, + skipConfirm: true, + })), +})); + +vi.mock("./rebuild-preflight-target-phase", async (importOriginal) => ({ + ...(await importOriginal()), + prepareRebuildTargetPreflights: mocks.prepareTargets, +})); + +import { runRebuildPreflightPhase } from "./rebuild-preflight-phase"; + +describe("rebuild baseline transition preflight (#7194)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSandbox.mockReturnValue({ + name: "alpha", + baselineExclusionTransition: { + id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", + operation: "restore", + exclusion: { + version: 1, + agent: "openclaw", + key: "agents.openclaw.default", + digest: "a".repeat(64), + }, + startedAt: "2026-07-19T00:00:00.000Z", + targetLiveDigest: "b".repeat(64), + }, + }); + }); + + it("stops before session probes, confirmation, MCP checks, or target preparation", async () => { + await expect(runRebuildPreflightPhase("alpha", ["--yes"])).resolves.toBeNull(); + + expect(mocks.bail).toHaveBeenCalledWith( + "Pending baseline policy restore for 'agents.openclaw.default' blocks rebuild.", + 1, + ); + expect(mocks.countActiveSessions).not.toHaveBeenCalled(); + expect(mocks.assertMcpDestroyNotPending).not.toHaveBeenCalled(); + expect(mocks.confirmRebuildIntent).not.toHaveBeenCalled(); + expect(mocks.prepareTargets).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts index 51ea8d6535d..3118635a740 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + captureOpenshell: vi.fn(), getSandbox: vi.fn(), listSandboxes: vi.fn(), prepareMcpForRebuild: vi.fn(), @@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../../adapters/openshell/runtime", () => ({ + captureOpenshell: mocks.captureOpenshell, runOpenshell: mocks.runOpenshell, })); @@ -47,7 +49,7 @@ vi.mock("./rebuild-mcp-phase", () => ({ reattachMcpAfterDeleteFailure: mocks.reattachMcpAfterDeleteFailure, })); -import { runRebuildDestroyPhase } from "./rebuild-destroy-phase"; +import { runRebuildDestroyPhase, waitForRebuildDeleteAbsence } from "./rebuild-destroy-phase"; describe("rebuild destroy phase", () => { beforeEach(() => { @@ -72,6 +74,45 @@ describe("rebuild destroy phase", () => { vi.restoreAllMocks(); }); + it("blocks rebuild before MCP or sandbox mutation when baseline repair is pending (#7178)", async () => { + const bail = vi.fn((message: string): never => { + throw new Error(message); + }); + + await expect( + runRebuildDestroyPhase({ + sandboxName: "alpha", + sandboxEntry: { + name: "alpha", + baselineExclusionTransition: { + id: "tx-1", + operation: "restore", + exclusion: { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "approved-digest", + }, + targetLiveDigest: "current-digest", + startedAt: "2026-07-19T00:00:00.000Z", + }, + }, + staleRecovery: false, + backupManifest: null, + log: vi.fn(), + bail, + relockShieldsIfNeeded: vi.fn(() => true), + onDeleted: vi.fn(), + }), + ).rejects.toThrow("Pending baseline policy restore"); + + expect(mocks.prepareMcpForRebuild).not.toHaveBeenCalled(); + expect(bail).toHaveBeenCalledWith( + "Pending baseline policy restore for 'nous_research' blocks rebuild.", + 1, + ); + }); + it("retains unexpected delete-edge diagnostics without logging credentials (#6195)", async () => { const secret = `nvapi-${"a".repeat(32)}`; const log = vi.fn(); @@ -105,24 +146,57 @@ describe("rebuild destroy phase", () => { expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true); }); + it("bounds delete convergence without treating timeout or gateway errors as absence (#7194)", async () => { + const { waitUntil: realWaitUntil } = + await vi.importActual("../../core/wait"); + mocks.waitUntil.mockImplementation(realWaitUntil); + + let currentMs = 0; + let attempts = 0; + const timeout = Object.assign(new Error("sandbox get timed out"), { code: "ETIMEDOUT" }); + const probeFailures = [ + { status: null, error: timeout }, + { status: 1, stderr: "gateway transport unavailable" }, + { status: 1, stderr: 'status: NotFound, message: "gateway not found"' }, + ]; + const captureSandboxGet = vi.fn(() => { + const failure = probeFailures[attempts % probeFailures.length]; + attempts += 1; + return failure; + }); + const sleep = vi.fn((milliseconds: number) => { + currentMs += milliseconds; + }); + + expect( + waitForRebuildDeleteAbsence("alpha", vi.fn(), { + captureSandboxGet, + now: () => currentMs, + sleep, + }), + ).toBe(false); + + expect(captureSandboxGet.mock.calls.length).toBeGreaterThan(1); + expect(captureSandboxGet.mock.calls.length).toBeLessThanOrEqual(20); + expect(sleep).toHaveBeenCalled(); + expect(currentMs).toBeLessThanOrEqual(15_000); + }); + it("removes registry state only after the gateway reports the deleted sandbox missing", async () => { const events: string[] = []; let getAttempts = 0; - const deleteResult = () => { + mocks.runOpenshell.mockImplementation(() => { events.push("delete"); return { status: 0, stdout: "deleted", stderr: "" }; - }; - const getResult = () => { + }); + mocks.captureOpenshell.mockImplementation(() => { getAttempts += 1; const isFirstProbe = getAttempts === 1; events.push(isFirstProbe ? "get-live" : "get-missing"); return isFirstProbe ? { status: 0, stdout: "Name: alpha\nPhase: Terminating", stderr: "" } : { status: 1, stdout: "", stderr: "Error: sandbox alpha not found" }; - }; - mocks.runOpenshell.mockImplementation((args: string[]) => - args[1] === "delete" ? deleteResult() : getResult(), - ); + }); mocks.removeSandboxRegistryEntryWithReceipt.mockImplementation(() => { events.push("remove-registry"); return null; @@ -148,11 +222,12 @@ describe("rebuild destroy phase", () => { }); it("preserves backup and registry state when transport failures prevent deletion confirmation", async () => { - mocks.runOpenshell.mockImplementation((args: string[]) => - args[1] === "delete" - ? { status: 0, stdout: "deleted", stderr: "" } - : { status: 1, stdout: "", stderr: "tcp connect error: Connection refused" }, - ); + mocks.runOpenshell.mockReturnValue({ status: 0, stdout: "deleted", stderr: "" }); + mocks.captureOpenshell.mockReturnValue({ + status: 1, + stdout: "", + stderr: "tcp connect error: Connection refused", + }); const onDeleted = vi.fn(); await expect( @@ -171,10 +246,11 @@ describe("rebuild destroy phase", () => { ).rejects.toThrow("Sandbox deletion could not be confirmed."); expect(onDeleted).not.toHaveBeenCalled(); - expect(mocks.runOpenshell).toHaveBeenCalledTimes(4); + expect(mocks.runOpenshell).toHaveBeenCalledTimes(1); + expect(mocks.captureOpenshell).toHaveBeenCalledTimes(3); expect(mocks.waitUntil).toHaveBeenCalledWith( expect.any(Function), - expect.objectContaining({ deadlineMs: expect.any(Number) }), + expect.objectContaining({ deadlineMs: expect.any(Number), maxAttempts: 20 }), ); expect(mocks.removeSandboxRegistryEntryWithReceipt).not.toHaveBeenCalled(); expect(mocks.listSandboxes).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index 12eb873cebb..e28493d4f06 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -1,11 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { runOpenshell } from "../../adapters/openshell/runtime"; -import { - OPENSHELL_HEAVY_TIMEOUT_MS, - OPENSHELL_PROBE_TIMEOUT_MS, -} from "../../adapters/openshell/timeouts"; +import { captureOpenshell, runOpenshell } from "../../adapters/openshell/runtime"; +import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import { G, R } from "../../cli/terminal-style"; import { waitUntil } from "../../core/wait"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; @@ -22,6 +19,7 @@ import { prepareMcpForRebuild, reattachMcpAfterDeleteFailure, } from "./rebuild-mcp-phase"; +import { blockRebuildOnPendingBaselineTransition } from "./rebuild-preflight-guards"; export type RebuildDeleteValidationResult = | { ok: true } @@ -43,21 +41,75 @@ export type RebuildDestroyPhaseResult = McpRebuildPreparation & { removalReceipt: registry.SandboxRemovalReceipt | null; }; -function waitForSandboxDeletion(sandboxName: string, log: RebuildLog): boolean { - return waitUntil( - () => { - const getResult = runOpenshell(["sandbox", "get", sandboxName], { +interface RebuildDeleteAbsenceDeps { + captureSandboxGet?: ( + sandboxName: string, + timeoutMs: number, + ) => { + status: number | null; + output?: string; + stdout?: string; + stderr?: string; + error?: Error; + }; + now?: () => number; + sleep?: (milliseconds: number) => void; +} + +const REBUILD_DELETE_ABSENCE_MAX_ATTEMPTS = 20; +const REBUILD_DELETE_ABSENCE_INITIAL_INTERVAL_MS = 250; +const REBUILD_DELETE_ABSENCE_MAX_INTERVAL_MS = 1_000; +const MISSING_SANDBOX_GET_OUTPUT = + /\b(?:no such sandbox|sandbox(?:\s+['"`]?[A-Za-z0-9._-]+['"`]?)?\s+(?:(?:was|is)\s+)?(?:not found|not present|does not exist|has no spec))\b/i; + +/** Wait for explicit absence from the same `sandbox get` boundary used by inner onboard. */ +export function waitForRebuildDeleteAbsence( + sandboxName: string, + log: RebuildLog, + deps: RebuildDeleteAbsenceDeps = {}, +): boolean { + const now = deps.now ?? Date.now; + const deadlineMs = now() + OPENSHELL_PROBE_TIMEOUT_MS; + const captureSandboxGet = + deps.captureSandboxGet ?? + ((name: string, timeoutMs: number) => { + const probe = captureOpenshell(["sandbox", "get", name], { ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: OPENSHELL_PROBE_TIMEOUT_MS, + includeStderr: true, + includeStreams: true, + timeout: timeoutMs, }); - const { alreadyGone, gatewayUnreachable } = getSandboxDeleteOutcome(getResult); - log( - `Delete convergence probe: exit=${getResult.status}, alreadyGone=${alreadyGone}, gatewayUnreachable=${gatewayUnreachable}`, - ); - return alreadyGone; + return probe; + }); + let attempt = 0; + + return waitUntil( + () => { + attempt += 1; + const remainingMs = Math.max(1, Math.ceil(deadlineMs - now())); + const probe = captureSandboxGet(sandboxName, remainingMs); + const stdout = String(probe.stdout ?? (probe.status === 0 ? probe.output : "")).trim(); + const combinedOutput = `${stdout}\n${String(probe.stderr ?? probe.output ?? "")}`.trim(); + const state = + !probe.error && + probe.status !== null && + probe.status !== 0 && + MISSING_SANDBOX_GET_OUTPUT.test(combinedOutput) + ? "absent" + : probe.status === 0 && stdout.length > 0 + ? "present" + : "unknown"; + log(`Delete convergence probe ${attempt}: status=${probe.status}, state=${state}`); + return state === "absent"; + }, + { + deadlineMs, + initialIntervalMs: REBUILD_DELETE_ABSENCE_INITIAL_INTERVAL_MS, + maxIntervalMs: REBUILD_DELETE_ABSENCE_MAX_INTERVAL_MS, + maxAttempts: REBUILD_DELETE_ABSENCE_MAX_ATTEMPTS, + now, + ...(deps.sleep ? { sleep: deps.sleep } : {}), }, - { deadlineMs: Date.now() + OPENSHELL_HEAVY_TIMEOUT_MS }, ); } @@ -80,6 +132,8 @@ export async function runRebuildDestroyPhase( onDeleted, } = input; + if (blockRebuildOnPendingBaselineTransition(input.sandboxEntry, sandboxName, bail)) return null; + // Step 3: Delete sandbox without tearing down gateway or session. // sandboxDestroy() cleans up the gateway when it's the last sandbox and // nulls session.sandboxName — both break the immediate onboard --resume. @@ -170,7 +224,7 @@ export async function runRebuildDestroyPhase( ); return null; } - const deletionConfirmed = alreadyGone || waitForSandboxDeletion(sandboxName, log); + const deletionConfirmed = alreadyGone || waitForRebuildDeleteAbsence(sandboxName, log); if (!deletionConfirmed) { console.error( " Sandbox delete was accepted, but OpenShell did not confirm that the sandbox is absent.", @@ -184,9 +238,11 @@ export async function runRebuildDestroyPhase( } onDeleted(); let removalReceipt: registry.SandboxRemovalReceipt | null = null; - if (rebuildMcpEntries.length === 0) { + const hasBaselineExclusions = (input.sandboxEntry.baselineExclusions?.length ?? 0) > 0; + if (rebuildMcpEntries.length === 0 && !hasBaselineExclusions) { removalReceipt = removeSandboxRegistryEntryWithReceipt(sandboxName); - } else { + } + if (rebuildMcpEntries.length > 0) { // The registry entry is the durable MCP rebuild transaction. The inner // onboard run observes that the sandbox is absent, carries the MCP state // into the replacement registration, and never enters generic live @@ -194,6 +250,12 @@ export async function runRebuildDestroyPhase( // successful delete and fresh registry registration. log("Preserving MCP-bearing registry entry across sandbox recreation"); } + if (hasBaselineExclusions) { + // Baseline exclusions are also registry-only rebuild intent. Keep the row + // until inner onboard snapshots it and replacement registration atomically + // publishes the fresh row. + log("Preserving baseline-exclusion registry entry across sandbox recreation"); + } log( `Registry after remove: ${JSON.stringify(registry.listSandboxes().sandboxes.map((s: { name: string }) => s.name))}`, ); diff --git a/src/lib/actions/sandbox/rebuild-finalization.ts b/src/lib/actions/sandbox/rebuild-finalization.ts index 66ff03c0a5d..511e672c094 100644 --- a/src/lib/actions/sandbox/rebuild-finalization.ts +++ b/src/lib/actions/sandbox/rebuild-finalization.ts @@ -148,7 +148,7 @@ export function finalizeRebuildPostRestore( } if (policyPresetRestoreIncomplete) { writeLine( - ` Policy presets failed to reapply: ${options.failedPresets.join(", ")} \u2014 re-apply manually with \`${CLI_NAME} ${options.sandboxName} policy-add\``, + ` Policy presets failed to reapply: ${options.failedPresets.join(", ")} \u2014 re-apply manually with \`${CLI_NAME} ${options.sandboxName} policy add\``, ); } } diff --git a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts index 354596ee8c5..47c56f4f787 100644 --- a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts +++ b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts @@ -18,6 +18,10 @@ import { createLocalInferenceRouteApplier } from "../../onboard/local-inference- const requireDist = createRequire(import.meta.url); const openshellRuntime = requireDist("../../adapters/openshell/runtime.js") as { + captureOpenshell( + args: string[], + options?: Record, + ): { status: number | null; output?: string; stdout?: string; stderr?: string }; runOpenshell( args: string[], options?: Record, @@ -144,6 +148,12 @@ describe("rebuild local-provider recreation", () => { credentialEnv, setup, }) => { + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ + status: 1, + output: "", + stdout: "", + stderr: "Not Found: sandbox not found", + }); let harness!: RebuildFlowHarness; let setupResult: SetupResult | undefined; harness = createRebuildFlowHarness({ diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index 904700eec98..6db604bd0f6 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -20,6 +20,7 @@ import { disposeRebuildAgentBaseImagePreflight } from "./rebuild-flow-helpers"; import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-phase"; import { runRebuildPostRestorePhase } from "./rebuild-post-restore-phase"; import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; +import { blockRebuildOnPendingBaselineTransition } from "./rebuild-preflight-guards"; import { runRebuildPreflightPhase } from "./rebuild-preflight-phase"; import { disposePreparedBuildContext, @@ -123,6 +124,7 @@ async function rebuildSandboxUnlocked( const preparedBackupRecovery = recoveryManifest !== null; const recoveryRecreate = staleRecovery || preparedBackupRecovery; try { + if (blockRebuildOnPendingBaselineTransition(sandboxEntry, sandboxName, bail)) return; let recoveryRegistrySnapshot = preparedBackupRecovery ? JSON.parse(JSON.stringify(registry.load())) : liveState.staleRegistrySnapshot; diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts index a0f6ef33ac6..4933a2c7d78 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -115,6 +115,29 @@ describe("rebuild post-restore phase", () => { expect(processRecovery.executeSandboxCommand).not.toHaveBeenCalled(); }); + it("discloses carried-over baseline exclusions in the successful rebuild summary (#7194)", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([ + { + version: 1, + agent: "hermes", + key: "nous_research", + digest: "digest-1", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + ]); + + await runRebuildPostRestorePhase(input()); + + expect( + logSpy.mock.calls.some( + (call) => + typeof call[0] === "string" && + call[0].includes("Baseline exclusions carried over: nous_research"), + ), + ).toBe(true); + }); + it("points Hermes rebuilds to the replacement API token retrieval command (#7175)", async () => { agentName = "hermes"; diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index 88e04e4374f..7e3207cfff9 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -7,6 +7,7 @@ import { CLI_NAME } from "../../cli/branding"; import { D, G, R, YW } from "../../cli/terminal-style"; import type { SandboxMessagingPlan } from "../../messaging"; import { normalizePolicyTierName } from "../../onboard/policy-tier-suppression"; +import { BASELINE_EXCLUSION_SUPPORT_IMPACT } from "../../policy/baseline-exclusion"; import type * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; import * as registry from "../../state/registry"; @@ -61,6 +62,19 @@ interface SuccessfulRebuildSummaryInput { expectedVersion: string | null; } +/** Disclose carried-over baseline exclusions and their support impact after a rebuild. */ +export function printBaselineExclusionsRebuildSummary( + sandboxName: string, + writeLine: (message: string) => void = console.log, +): void { + const exclusions = registry.getBaselineExclusions(sandboxName); + if (exclusions.length === 0) return; + const keys = exclusions.map((exclusion) => exclusion.key).join(", "); + writeLine( + ` Baseline exclusions carried over: ${keys} \u2014 ${BASELINE_EXCLUSION_SUPPORT_IMPACT}`, + ); +} + export function printSuccessfulRebuildSummary( input: SuccessfulRebuildSummaryInput, writeLine: (message: string) => void = console.log, @@ -78,6 +92,7 @@ export function printSuccessfulRebuildSummary( if (input.expectedVersion) { writeLine(` Now running: ${input.rebuiltAgentName} v${input.expectedVersion}`); } + printBaselineExclusionsRebuildSummary(input.sandboxName, writeLine); } function printHermesApiTokenChangeNotice(sandboxName: string, targetAgentName: string): void { @@ -301,15 +316,16 @@ export async function runRebuildPostRestorePhase( } printHermesGatewayRestoreRecovery(sandboxName, hermesGatewayRestoreState); printMcpRestoreRecovery(sandboxName, mcpBridgeRestoreUnverified); + printBaselineExclusionsRebuildSummary(sandboxName); if (policyPresetRestoreIncomplete) { if (failedPresets.length > 0) { console.log( - ` Policy presets failed to reapply: ${failedPresets.join(", ")} \u2014 re-apply manually with \`${CLI_NAME} ${sandboxName} policy-add\``, + ` Policy presets failed to reapply: ${failedPresets.join(", ")} \u2014 re-apply manually with \`${CLI_NAME} ${sandboxName} policy add\``, ); } if (failedPresetRemovals.length > 0 || !policyPresetReconciliationVerified) { console.log( - ` Exact live policy reconciliation was incomplete${failedPresetRemovals.length > 0 ? `; remove failed: ${failedPresetRemovals.join(", ")}` : ""} \u2014 reconcile manually with \`${CLI_NAME} ${sandboxName} policy-add\` or \`${CLI_NAME} ${sandboxName} policy-remove\``, + ` Exact live policy reconciliation was incomplete${failedPresetRemovals.length > 0 ? `; remove failed: ${failedPresetRemovals.join(", ")}` : ""} \u2014 reconcile manually with \`${CLI_NAME} ${sandboxName} policy add\` or \`${CLI_NAME} ${sandboxName} policy remove\``, ); } } diff --git a/src/lib/actions/sandbox/rebuild-preflight-guards.ts b/src/lib/actions/sandbox/rebuild-preflight-guards.ts index 02ee2e61a8e..9e086abfc4b 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-guards.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-guards.ts @@ -54,6 +54,25 @@ export function getRebuildSandboxEntryOrBail( return sb; } +/** Keep the pending baseline-policy transaction guard identical at every rebuild boundary. */ +export function blockRebuildOnPendingBaselineTransition( + sandboxEntry: RebuildSandboxEntry, + sandboxName: string, + bail: RebuildBail, +): boolean { + const transition = sandboxEntry.baselineExclusionTransition; + if (!transition) return false; + + const key = transition.exclusion.key; + console.error(""); + console.error( + ` Baseline policy ${transition.operation} for '${key}' needs repair before rebuild.`, + ); + console.error(` Re-run: ${CLI_NAME} ${sandboxName} policy ${transition.operation} ${key}`); + bail(`Pending baseline policy ${transition.operation} for '${key}' blocks rebuild.`, 1); + return true; +} + export function isSingleAgentRebuildSupported( sb: registry.SandboxEntry & { agents?: unknown[] }, bail: RebuildBail, diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index a91ff5347e4..03a9c3b1296 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -38,6 +38,7 @@ import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; import { acquireRebuildOnboardLock, assertRebuildEntryUnchanged, + blockRebuildOnPendingBaselineTransition, checkRebuildGatewaySchemaPreflight, expectedRebuildEntryAfterVersionCheck, getRebuildSandboxEntryOrBail, @@ -90,9 +91,10 @@ export async function runRebuildPreflightPhase( requestedObservabilityEnabled, skipConfirm, } = createRebuildCommandContext(options, opts); - const activeSessionCount = countActiveSandboxSessionsForRebuild(sandboxName); const sandboxEntry = getRebuildSandboxEntryOrBail(sandboxName, bail); if (!sandboxEntry) return null; + if (blockRebuildOnPendingBaselineTransition(sandboxEntry, sandboxName, bail)) return null; + const activeSessionCount = countActiveSandboxSessionsForRebuild(sandboxName); // #6376: refuse a stuck MCP destroy transaction up front — before backup, // image prep, or the old-sandbox delete. The only MCP marker check used to // live inside the destroy phase, which runs AFTER the backup phase, so a diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.ts b/src/lib/actions/sandbox/rebuild-restore-phase.ts index 809865a5089..2ce0640077c 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.ts @@ -284,7 +284,7 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild } if (failedPresets.length > 0) { console.error(` ${YW}\u26a0${R} Failed to restore presets: ${failedPresets.join(", ")}`); - console.error(` Re-apply manually with: ${CLI_NAME} ${sandboxName} policy-add`); + console.error(` Re-apply manually with: ${CLI_NAME} ${sandboxName} policy add`); } } diff --git a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts index f7a2bbdcbc9..8f0ca4238ed 100644 --- a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts @@ -155,17 +155,15 @@ describe("rebuild resume snapshot repair", () => { policyPresets: [], }, } as never), - vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation((args: unknown) => { - const argv = Array.isArray(args) ? args.map(String) : []; - return argv.join(" ") === "sandbox get alpha" - ? ({ - status: 1, - output: "sandbox alpha not found", - stdout: "", - stderr: "sandbox alpha not found", - } as never) - : ({ status: 0, output: "", stdout: "", stderr: "" } as never); - }), + vi + .spyOn(openshellRuntime, "runOpenshell") + .mockReturnValue({ status: 0, output: "" } as never), + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ + status: 1, + output: "", + stdout: "", + stderr: "Not Found: sandbox not found", + } as never), vi.spyOn(destroy, "removeSandboxRegistryEntry").mockReturnValue(true), vi.spyOn(nim, "stopNimContainer").mockImplementation(() => undefined), vi.spyOn(nim, "stopNimContainerByName").mockImplementation(() => undefined), diff --git a/src/lib/actions/sandbox/rebuild-shields-finally.test.ts b/src/lib/actions/sandbox/rebuild-shields-finally.test.ts index e4dce6b54a9..85957000082 100644 --- a/src/lib/actions/sandbox/rebuild-shields-finally.test.ts +++ b/src/lib/actions/sandbox/rebuild-shields-finally.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const phaseMocks = vi.hoisted(() => ({ runBackup: vi.fn(), + runDestroy: vi.fn(), runPreflight: vi.fn(), runShields: vi.fn(), })); @@ -17,6 +18,10 @@ vi.mock("./rebuild-preflight-phase", () => ({ runRebuildPreflightPhase: phaseMocks.runPreflight, })); +vi.mock("./rebuild-destroy-phase", () => ({ + runRebuildDestroyPhase: phaseMocks.runDestroy, +})); + vi.mock("./rebuild-shields-phase", () => ({ runRebuildShieldsPhase: phaseMocks.runShields, })); @@ -66,4 +71,48 @@ describe("rebuild shields relock guard", () => { expect(relockShields).toHaveBeenCalledWith(true); expect(rebuildWindow.relocked).toBe(true); }); + + it("blocks a pending baseline transition before shields, backup, or destroy phases begin (#7194)", async () => { + const bail = vi.fn(); + phaseMocks.runPreflight.mockResolvedValue({ + sandboxEntry: { + name: "alpha", + customPolicies: [], + baselineExclusionTransition: { + id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", + operation: "restore", + exclusion: { + version: 1, + agent: "openclaw", + key: "agents.openclaw.default", + digest: "a".repeat(64), + }, + startedAt: "2026-07-19T00:00:00.000Z", + targetLiveDigest: "b".repeat(64), + }, + }, + targetConfig: { durableConfig: { webSearchConfig: null } }, + recreateOptions: { observabilityEnabled: false }, + liveState: { staleRecovery: false, staleRegistrySnapshot: null }, + recoveryManifest: null, + dcodePreflight: { cleanup: cleanupDcodePreflight }, + preparedImage: null, + releaseOnboardLock, + log: vi.fn(), + bail, + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await rebuildSandbox("alpha", ["--yes"], { throwOnError: true }); + + expect(bail).toHaveBeenCalledWith( + "Pending baseline policy restore for 'agents.openclaw.default' blocks rebuild.", + 1, + ); + expect(phaseMocks.runShields).not.toHaveBeenCalled(); + expect(phaseMocks.runBackup).not.toHaveBeenCalled(); + expect(phaseMocks.runDestroy).not.toHaveBeenCalled(); + expect(cleanupDcodePreflight).toHaveBeenCalledOnce(); + expect(releaseOnboardLock).toHaveBeenCalledOnce(); + }); }); diff --git a/src/lib/actions/sandbox/restore-gateway-pairing.test.ts b/src/lib/actions/sandbox/restore-gateway-pairing.test.ts index 81cbd251979..2be59c268ca 100644 --- a/src/lib/actions/sandbox/restore-gateway-pairing.test.ts +++ b/src/lib/actions/sandbox/restore-gateway-pairing.test.ts @@ -3,15 +3,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { establishRestoredSandboxGatewayPairing } from "./restore-gateway-pairing"; +import { + establishRestoredSandboxGatewayPairing, + restartRestoredSandboxGateway, +} from "./restore-gateway-pairing"; afterEach(() => { vi.restoreAllMocks(); }); describe("establishRestoredSandboxGatewayPairing", () => { - it("provokes the scope upgrade before approving it (#7431)", () => { + it("restarts the restored gateway before provoking and approving the scope upgrade (#7431)", async () => { const order: string[] = []; + const restartRestoredSandboxGateway = vi.fn(() => { + order.push("restart"); + }); const warmupScopeUpgrade = vi.fn(() => order.push("warmup")); const autoPairScopeApproval = vi.fn(() => order.push("approve")); const verifyGatewayPairing = vi.fn(() => { @@ -19,43 +25,168 @@ describe("establishRestoredSandboxGatewayPairing", () => { return true; }); - establishRestoredSandboxGatewayPairing("beta", { + await establishRestoredSandboxGatewayPairing("beta", { + restartRestoredSandboxGateway, warmupScopeUpgrade, autoPairScopeApproval, verifyGatewayPairing, }); + expect(restartRestoredSandboxGateway).toHaveBeenCalledWith("beta"); expect(warmupScopeUpgrade).toHaveBeenCalledWith("beta"); expect(autoPairScopeApproval).toHaveBeenCalledWith("beta"); expect(verifyGatewayPairing).toHaveBeenCalledWith("beta"); - expect(order).toEqual(["warmup", "approve", "verify"]); + expect(order).toEqual(["restart", "warmup", "approve", "verify"]); }); - it("fails when the pairing warm-up does not complete (#7431)", () => { + it("repeats the handshake once when verification creates a remaining scope upgrade (#7431)", async () => { + const order: string[] = []; + const verifyGatewayPairing = vi + .fn() + .mockImplementationOnce(() => { + order.push("verify"); + return false; + }) + .mockImplementationOnce(() => { + order.push("verify"); + return true; + }); + + await establishRestoredSandboxGatewayPairing("beta", { + restartRestoredSandboxGateway: vi.fn(() => { + order.push("restart"); + }), + warmupScopeUpgrade: vi.fn(() => order.push("warmup")), + autoPairScopeApproval: vi.fn(() => order.push("approve")), + verifyGatewayPairing, + }); + + expect(order).toEqual([ + "restart", + "warmup", + "approve", + "verify", + "restart", + "warmup", + "approve", + "verify", + ]); + }); + + it("restarts the gateway before verifying registration approved by the first attempt (#7431)", async () => { + let lifecycleGeneration = 0; + let approvedGeneration: number | null = null; + let approvalPending = false; + const restartRestoredSandboxGateway = vi.fn(() => { + lifecycleGeneration += 1; + }); + const warmupScopeUpgrade = vi.fn(() => { + approvalPending = approvedGeneration === null; + }); + const autoPairScopeApproval = vi.fn(() => { + approvedGeneration = approvalPending ? lifecycleGeneration : approvedGeneration; + approvalPending = false; + }); + const verifyGatewayPairing = vi.fn( + () => approvedGeneration !== null && lifecycleGeneration > approvedGeneration, + ); + + await establishRestoredSandboxGatewayPairing("beta", { + restartRestoredSandboxGateway, + warmupScopeUpgrade, + autoPairScopeApproval, + verifyGatewayPairing, + }); + + expect(restartRestoredSandboxGateway).toHaveBeenCalledTimes(2); + expect(warmupScopeUpgrade).toHaveBeenCalledTimes(2); + expect(autoPairScopeApproval).toHaveBeenCalledTimes(2); + expect(verifyGatewayPairing).toHaveBeenCalledTimes(2); + }); + + it("fails before pairing when the restored gateway cannot restart (#7431)", async () => { + const warmupScopeUpgrade = vi.fn(); + const autoPairScopeApproval = vi.fn(); + const verifyGatewayPairing = vi.fn(() => true); + + await expect( + establishRestoredSandboxGatewayPairing("beta", { + restartRestoredSandboxGateway: vi.fn(() => { + throw new Error("gateway did not restart"); + }), + warmupScopeUpgrade, + autoPairScopeApproval, + verifyGatewayPairing, + }), + ).rejects.toThrow("gateway did not restart"); + expect(warmupScopeUpgrade).not.toHaveBeenCalled(); + expect(autoPairScopeApproval).not.toHaveBeenCalled(); + expect(verifyGatewayPairing).not.toHaveBeenCalled(); + }); + + it("fails when the pairing warm-up does not complete (#7431)", async () => { const warmupScopeUpgrade = vi.fn(() => { throw new Error("gateway not up"); }); const autoPairScopeApproval = vi.fn(); const verifyGatewayPairing = vi.fn(() => true); - expect(() => + await expect( establishRestoredSandboxGatewayPairing("beta", { + restartRestoredSandboxGateway: vi.fn(() => {}), warmupScopeUpgrade, autoPairScopeApproval, verifyGatewayPairing, }), - ).toThrow("gateway not up"); + ).rejects.toThrow("gateway not up"); expect(autoPairScopeApproval).not.toHaveBeenCalled(); expect(verifyGatewayPairing).not.toHaveBeenCalled(); }); - it("fails when the authenticated verification run cannot use the restored gateway (#7431)", () => { - expect(() => + it("fails when two authenticated verification runs cannot use the restored gateway (#7431)", async () => { + const restartRestoredSandboxGateway = vi.fn(); + const warmupScopeUpgrade = vi.fn(); + const autoPairScopeApproval = vi.fn(); + const verifyGatewayPairing = vi.fn(() => false); + + await expect( establishRestoredSandboxGatewayPairing("beta", { - warmupScopeUpgrade: vi.fn(), - autoPairScopeApproval: vi.fn(), - verifyGatewayPairing: vi.fn(() => false), + restartRestoredSandboxGateway, + warmupScopeUpgrade, + autoPairScopeApproval, + verifyGatewayPairing, + }), + ).rejects.toThrow("authenticated gateway verification run did not succeed"); + expect(restartRestoredSandboxGateway).toHaveBeenCalledTimes(2); + expect(warmupScopeUpgrade).toHaveBeenCalledTimes(2); + expect(autoPairScopeApproval).toHaveBeenCalledTimes(2); + expect(verifyGatewayPairing).toHaveBeenCalledTimes(2); + }); +}); + +describe("restartRestoredSandboxGateway", () => { + it("restarts through the existing supervisor-mediated gateway lifecycle (#7431)", () => { + const restartSandboxGateway = vi.fn(() => ({ + ok: true as const, + restarted: true as const, + healthPassed: true as const, + forwardRecovered: true, + })); + + restartRestoredSandboxGateway("beta", { restartSandboxGateway }); + + expect(restartSandboxGateway).toHaveBeenCalledWith("beta", { quiet: true }); + }); + + it("propagates the classified gateway restart failure (#7431)", () => { + expect(() => + restartRestoredSandboxGateway("beta", { + restartSandboxGateway: () => ({ + ok: false, + failureLayer: "health timeout", + detail: "gateway process did not become healthy", + }), }), - ).toThrow("authenticated gateway verification run did not succeed"); + ).toThrow("health timeout: gateway process did not become healthy"); }); }); diff --git a/src/lib/actions/sandbox/restore-gateway-pairing.ts b/src/lib/actions/sandbox/restore-gateway-pairing.ts index a9e16361fc5..f1b00bbad4c 100644 --- a/src/lib/actions/sandbox/restore-gateway-pairing.ts +++ b/src/lib/actions/sandbox/restore-gateway-pairing.ts @@ -2,18 +2,49 @@ // SPDX-License-Identifier: Apache-2.0 import { verifyRestoredSandboxGatewayPairing } from "../../adapters/openshell/restore-gateway-pairing"; +import type { GatewayRestartResult } from "./gateway-restart"; import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; export type RestoreGatewayPairingDeps = { + restartRestoredSandboxGateway: (sandboxName: string) => void; warmupScopeUpgrade: (sandboxName: string) => void; autoPairScopeApproval: (sandboxName: string) => void; verifyGatewayPairing: (sandboxName: string) => boolean; }; +// A restored clone starts without runtime device credentials. Its first pass +// can approve initial pairing, while strict verification can provoke the +// remaining operator.write upgrade. Permit one bounded recovery pass. +const RESTORE_GATEWAY_PAIRING_ATTEMPTS = 2; + +type RestoredSandboxGatewayRestartDeps = { + restartSandboxGateway: ( + sandboxName: string, + options?: { quiet?: boolean }, + ) => GatewayRestartResult; +}; + +function defaultRestoredSandboxGatewayRestartDeps(): RestoredSandboxGatewayRestartDeps { + const { restartSandboxGateway }: typeof import("./process-recovery") = + require("./process-recovery"); + return { restartSandboxGateway }; +} + +export function restartRestoredSandboxGateway( + sandboxName: string, + deps: RestoredSandboxGatewayRestartDeps = defaultRestoredSandboxGatewayRestartDeps(), +): void { + const result = deps.restartSandboxGateway(sandboxName, { quiet: true }); + if (!result.ok) { + throw new Error(`${result.failureLayer}: ${result.detail}`); + } +} + function defaultRestoreGatewayPairingDeps(): RestoreGatewayPairingDeps { const warmup: typeof import("./auto-pair-warmup") = require("./auto-pair-warmup"); const connect: typeof import("./connect") = require("./connect"); return { + restartRestoredSandboxGateway, warmupScopeUpgrade: warmup.runSandboxScopeWarmupRun, autoPairScopeApproval: connect.runConnectAutoPairApprovalPass, verifyGatewayPairing: (sandboxName) => @@ -21,16 +52,23 @@ function defaultRestoreGatewayPairingDeps(): RestoreGatewayPairingDeps { }; } -export function establishRestoredSandboxGatewayPairing( +export async function establishRestoredSandboxGatewayPairing( targetSandbox: string, deps: RestoreGatewayPairingDeps = defaultRestoreGatewayPairingDeps(), -): void { +): Promise { try { - deps.warmupScopeUpgrade(targetSandbox); - deps.autoPairScopeApproval(targetSandbox); - if (!deps.verifyGatewayPairing(targetSandbox)) { - throw new Error("the authenticated gateway verification run did not succeed"); + for (let attempt = 0; attempt < RESTORE_GATEWAY_PAIRING_ATTEMPTS; attempt += 1) { + // Registration approved by the first pass is not visible until the + // gateway restarts. Repeat supervisor-mediated restart and host-forward + // recovery before every bounded attempt. + deps.restartRestoredSandboxGateway(targetSandbox); + deps.warmupScopeUpgrade(targetSandbox); + deps.autoPairScopeApproval(targetSandbox); + if (deps.verifyGatewayPairing(targetSandbox)) { + return; + } } + throw new Error("the authenticated gateway verification run did not succeed"); } catch (err) { throw new Error( `could not establish gateway pairing for '${targetSandbox}': ${ diff --git a/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts b/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts index b6959ee9be9..65612e0ae3d 100644 --- a/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts +++ b/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; +import { resolveTestAgentBaselinePolicy } from "../../../../test/support/snapshot-policy-test-fixture"; import type { SnapshotStreamSandboxCreateMock } from "./snapshot-create-stream-test-types"; const captureOpenshellMock = vi.fn(() => ({ status: 0, output: "alpha Ready\n" })); @@ -60,6 +61,7 @@ vi.mock("../../policy", () => ({ getPresetContentGatewayState: vi.fn(() => "absent"), loadPresetForSandbox: vi.fn(() => null), removePreset: vi.fn(() => true), + resolveAgentBaselinePolicy: resolveTestAgentBaselinePolicy, })); vi.mock("../../runner", () => ({ ROOT: "/repo", diff --git a/src/lib/actions/sandbox/snapshot-baseline-exclusion-output.test.ts b/src/lib/actions/sandbox/snapshot-baseline-exclusion-output.test.ts new file mode 100644 index 00000000000..c3017fcb8bd --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-baseline-exclusion-output.test.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + backupSandboxState: vi.fn(), + captureOpenshell: vi.fn(() => ({ status: 0, output: "alpha Ready\n" })), + findBackup: vi.fn(), + getBaselineExclusions: vi.fn(), +})); + +vi.mock("../../adapters/openshell/runtime", () => ({ + captureOpenshell: mocks.captureOpenshell, + getOpenshellBinary: vi.fn(() => "openshell"), + runOpenshell: vi.fn(), +})); + +vi.mock("../../runtime-recovery", () => ({ + parseLiveSandboxNames: vi.fn(() => new Set(["alpha"])), +})); + +vi.mock("../../shields", () => ({ + isShieldsDown: vi.fn(() => true), +})); + +vi.mock("../../shields/timer-bound-lock", () => ({ + withTimerBoundShieldsMutationLock: vi.fn( + (_sandboxName: string, _command: string, operation: () => unknown) => operation(), + ), +})); + +vi.mock("../../state/registry", () => ({ + getBaselineExclusions: mocks.getBaselineExclusions, + getSandbox: vi.fn(() => ({ name: "alpha", agent: "hermes" })), +})); + +vi.mock("../../state/sandbox", () => ({ + backupSandboxState: mocks.backupSandboxState, + findBackup: mocks.findBackup, +})); + +vi.mock("./sandbox-gateway-routing", () => ({ + probeGatewayRunning: vi.fn(() => true), + selectSandboxGatewayIfRegistered: vi.fn(() => true), + usesGatewayMetadataProbe: vi.fn(() => false), +})); + +describe("snapshot baseline exclusion output", () => { + beforeEach(() => { + vi.clearAllMocks(); + const manifest = { + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + }; + mocks.backupSandboxState.mockReturnValue({ + success: true, + backedUpDirs: ["workspace"], + backedUpFiles: ["openclaw.json"], + failedDirs: [], + failedFiles: [], + manifest, + }); + mocks.findBackup.mockReturnValue({ match: { ...manifest, snapshotVersion: 7 } }); + mocks.getBaselineExclusions.mockReturnValue([{ key: "nous_research", digest: "a".repeat(64) }]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports active exclusions and support impact after a successful snapshot (#7178)", async () => { + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "create" }); + + const output = consoleLog.mock.calls.flat().join("\n"); + expect(output).toContain("Active baseline exclusions: nous_research"); + expect(output).toContain( + "Support impact: Excluded egress leaves dependent agent features unsupported for this sandbox.", + ); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.test.ts b/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.test.ts new file mode 100644 index 00000000000..22ad173ca7b --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.test.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { formatSnapshotBaselineExclusionSummary } from "./snapshot-baseline-exclusion-summary"; + +describe("formatSnapshotBaselineExclusionSummary (#7178)", () => { + it("discloses active exclusions and their support impact", () => { + expect( + formatSnapshotBaselineExclusionSummary([ + { version: 1, agent: "hermes", key: "nous_research", digest: "digest-a" }, + { version: 1, agent: "hermes", key: "managed_inference", digest: "digest-b" }, + ]), + ).toEqual([ + "Active baseline exclusions: nous_research, managed_inference", + expect.stringMatching(/^Support impact: .*unsupported/), + ]); + }); + + it("omits the summary when no exclusions are active", () => { + expect(formatSnapshotBaselineExclusionSummary([])).toEqual([]); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.ts b/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.ts new file mode 100644 index 00000000000..73e2775c476 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-baseline-exclusion-summary.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type BaselineExclusionRequest, + BASELINE_EXCLUSION_SUPPORT_IMPACT, +} from "../../policy/baseline-exclusion"; + +export function formatSnapshotBaselineExclusionSummary( + exclusions: readonly BaselineExclusionRequest[], +): string[] { + if (exclusions.length === 0) return []; + return [ + `Active baseline exclusions: ${exclusions.map((entry) => entry.key).join(", ")}`, + `Support impact: ${BASELINE_EXCLUSION_SUPPORT_IMPACT}`, + ]; +} diff --git a/src/lib/actions/sandbox/snapshot-restore-baseline-exclusions.test.ts b/src/lib/actions/sandbox/snapshot-restore-baseline-exclusions.test.ts new file mode 100644 index 00000000000..decee072eb6 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-restore-baseline-exclusions.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as f from "./snapshot-restore-test-fixture"; + +beforeEach(f.resetSnapshotRestoreMocks); +afterEach(f.cleanupSnapshotRestoreMocks); + +describe("runSandboxSnapshot restore: baseline exclusions", () => { + it("creates a clone with the source exclusions applied to its live policy (#7178)", async () => { + const exclusion = { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: "0.18.0", + }; + const cleanup = vi.fn(() => true); + let registeredClone: f.SandboxRecord | null = null; + f.registerSandboxMock.mockImplementation( + (entry) => (registeredClone = entry as f.SandboxRecord), + ); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent: "hermes", + imageTag: "nemoclaw-alpha:test", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + baselineExclusions: [exclusion], + } + : registeredClone, + ); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + f.prepareInitialSandboxCreatePolicyMock.mockReturnValue({ + policyPath: "/tmp/snapshot-clone-policy.yaml", + appliedPresets: [], + cleanup, + }); + + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); + + expect(f.resolveAgentBaselinePolicyMock).toHaveBeenCalledWith("hermes"); + expect(f.prepareInitialSandboxCreatePolicyMock).toHaveBeenCalledWith( + "/repo/agents/hermes/policy-additions.yaml", + [], + { agentName: "hermes", baselineExclusions: [exclusion] }, + ); + const createArgs = f.streamSandboxCreateMock.mock.calls[0]?.[1] ?? []; + expect(createArgs[createArgs.indexOf("--policy") + 1]).toBe("/tmp/snapshot-clone-policy.yaml"); + expect(f.registerSandboxMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "beta", baselineExclusions: [exclusion] }), + ); + expect(cleanup).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts index 57b1de4e173..d1fbc6da9b1 100644 --- a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts @@ -1,14 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withSandboxMutationLock } from "../../state/mcp-lifecycle-lock"; import * as f from "./snapshot-restore-test-fixture"; +const tempHomes: string[] = []; beforeEach(() => { f.resetSnapshotRestoreMocks(); }); -afterEach(f.cleanupSnapshotRestoreMocks); +afterEach(() => { + f.cleanupSnapshotRestoreMocks(); + for (const tempHome of tempHomes.splice(0)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } +}); describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { it("restores the latest snapshot into the source sandbox", async () => { const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -189,6 +199,188 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); expect(f.registerSandboxMock).not.toHaveBeenCalled(); }); + + it("holds the source and destination mutation locks until a cross-sandbox restore finishes (#7178)", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-snapshot-locks-")); + tempHomes.push(tempHome); + vi.stubEnv("HOME", tempHome); + const events: string[] = []; + let cloneCreated = false; + let releaseCreate: (() => void) | undefined; + let signalCreateStarted: (() => void) | undefined; + const createStarted = new Promise((resolve) => { + signalCreateStarted = resolve; + }); + const createRelease = new Promise((resolve) => { + releaseCreate = resolve; + }); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent: "openclaw", + imageTag: "nemoclaw-alpha:test", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + } + : null, + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { + status: 0, + output: cloneCreated ? "alpha Ready\nbeta Ready\n" : "alpha Ready\n", + }, + }), + ); + f.streamSandboxCreateMock.mockImplementation(async () => { + events.push("create-started"); + signalCreateStarted?.(); + await createRelease; + cloneCreated = true; + events.push("create-released"); + return { status: 0, output: "", sawProgress: false, forcedReady: false }; + }); + f.restoreSandboxStateMock.mockImplementation(() => { + events.push("snapshot-restored"); + return { + success: true, + restoredDirs: ["workspace"], + restoredFiles: [], + failedDirs: [], + failedFiles: [], + }; + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + const restore = runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); + await createStarted; + const sourceMutation = withSandboxMutationLock("alpha", () => { + events.push("source-mutation"); + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + expect(events).toEqual(["create-started"]); + + releaseCreate?.(); + await restore; + await sourceMutation; + + expect(events).toEqual([ + "create-started", + "create-released", + "snapshot-restored", + "source-mutation", + ]); + }); + + it("blocks a cross-sandbox clone before deleting the target when source policy repair is pending (#7178)", async () => { + const common = { + agent: "openclaw", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + }; + f.getSandboxMock.mockImplementation((name) => { + return name === "alpha" + ? { + ...common, + name: "alpha", + imageTag: "nemoclaw-alpha:test", + baselineExclusionTransition: { + id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", + operation: "restore", + exclusion: { + version: 1 as const, + agent: "openclaw", + key: "agents.openclaw.default", + digest: "a".repeat(64), + }, + startedAt: "2026-07-19T00:00:00.000Z", + targetLiveDigest: "b".repeat(64), + }, + } + : name === "beta" + ? { ...common, name: "beta", imageTag: "nemoclaw-beta:test" } + : null; + }); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect( + runSandboxSnapshot("alpha", { + kind: "restore", + to: "beta", + force: true, + yes: true, + }), + ).rejects.toThrow( + "Cannot clone baseline policy while 'restore agents.openclaw.default' needs repair", + ); + + expect(f.lifecycleMock.events).not.toContain("delete"); + expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); + expect(f.registerSandboxMock).not.toHaveBeenCalled(); + }); + + it("blocks a forced clone before deleting a destination whose policy repair is pending (#7178)", async () => { + const pendingTransition = { + id: "0b2f3297-a9ab-4c2f-80da-bf1760a1afbf", + operation: "restore" as const, + exclusion: { + version: 1 as const, + agent: "openclaw", + key: "agents.openclaw.default", + digest: "a".repeat(64), + }, + startedAt: "2026-07-19T00:00:00.000Z", + targetLiveDigest: "b".repeat(64), + }; + f.getSandboxMock.mockImplementation((name) => + name + ? { + name, + agent: "openclaw", + imageTag: `nemoclaw-${name}:test`, + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + ...(name === "beta" ? { baselineExclusionTransition: pendingTransition } : {}), + } + : null, + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect( + runSandboxSnapshot("alpha", { + kind: "restore", + to: "beta", + force: true, + yes: true, + }), + ).rejects.toMatchObject({ exitCode: 1 }); + + expect(f.lifecycleMock.events).not.toContain("delete"); + expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); + expect(f.registerSandboxMock).not.toHaveBeenCalled(); + }); }); describe("runSandboxSnapshot restore: gateway pairing on a freshly created destination", () => { diff --git a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts index 1579d5e477e..07810b81f04 100644 --- a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts +++ b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts @@ -16,6 +16,28 @@ export type OpenshellCaptureResult = { export type SandboxRecord = { name: string; agent?: string | null; + baselineExclusionTransition?: { + id: string; + operation: "exclude" | "restore"; + exclusion: { + version: 1; + agent: string; + key: string; + digest: string; + acknowledgedAt?: string; + appliedAgentVersion?: string | null; + }; + startedAt: string; + targetLiveDigest: string | null; + }; + baselineExclusions?: Array<{ + version: 1; + agent: string; + key: string; + digest: string; + acknowledgedAt?: string; + appliedAgentVersion?: string | null; + }>; fromDockerfile?: string | null; gatewayName?: string | null; imageTag?: string | null; @@ -104,6 +126,10 @@ const lifecycleMock = vi.hoisted(() => { }); export const backupSandboxStateMock = vi.fn(); +export const loadAgentMock = vi.fn((name: string) => ({ + name, + policyAdditionsPath: name === "openclaw" ? null : `/repo/agents/${name}/policy-additions.yaml`, +})); export const captureOpenshellMock = vi.fn< (args: string[], opts?: Record) => OpenshellCaptureResult >((args) => defaultOpenshellResponses(args)); @@ -123,6 +149,14 @@ export const removePresetMock = vi.fn((_sandbox: string, _preset: string) => tru export const getPresetContentGatewayStateMock = vi.fn< (_sandbox: string, _content: string, _policyKey?: string) => "match" | "absent" | "drift" | null >(() => "absent"); +export const resolveAgentBaselinePolicyMock = vi.fn((agent: string) => ({ + agent, + policyPath: + agent === "openclaw" + ? "/repo/nemoclaw-blueprint/policies/openclaw-sandbox.yaml" + : `/repo/agents/${agent}/policy-additions.yaml`, + content: "version: 1\nnetwork_policies: {}\n", +})); export const builtinObservabilityPolicy = "network_policies:\n observability-otlp-local:\n endpoints:\n - host: host.openshell.internal\n"; export const loadPresetForSandboxMock = vi.fn((_sandbox: string, preset: string) => @@ -132,6 +166,14 @@ export const getSandboxMock = vi.fn<(name?: string) => SandboxRecord | null>(() export const isGatewayHealthyMock = vi.fn(() => true); export const listBackupsMock = vi.fn<() => Array>>(() => []); export const parseLiveSandboxNamesMock = vi.fn(() => new Set(["alpha"])); +export const prepareInitialSandboxCreatePolicyMock = vi.fn( + ( + policyPath: string, + ): { policyPath: string; appliedPresets: string[]; cleanup?: () => boolean } => ({ + policyPath, + appliedPresets: [], + }), +); export const registerSandboxMock = vi.fn(); export const updateSandboxMock = vi.fn(); export const restoreSandboxStateMock = vi.fn(); @@ -157,6 +199,10 @@ vi.mock("../../adapters/docker", () => ({ dockerInspect: dockerInspectMock, })); +vi.mock("../../agent/defs", () => ({ + loadAgent: loadAgentMock, +})); + vi.mock("../../adapters/openshell/runtime", () => ({ captureOpenshell: captureOpenshellMock, getOpenshellBinary: vi.fn(() => "openshell"), @@ -183,6 +229,7 @@ vi.mock("../../policy", () => ({ getPresetContentGatewayState: getPresetContentGatewayStateMock, loadPresetForSandbox: loadPresetForSandboxMock, removePreset: removePresetMock, + resolveAgentBaselinePolicy: resolveAgentBaselinePolicyMock, })); vi.mock("../../runner", () => ({ @@ -196,6 +243,10 @@ vi.mock("../../runtime-recovery", () => ({ parseLiveSandboxNames: parseLiveSandboxNamesMock, })); +vi.mock("../../onboard/initial-policy", () => ({ + prepareInitialSandboxCreatePolicy: prepareInitialSandboxCreatePolicyMock, +})); + vi.mock("../../shields", () => ({ get isShieldsDown() { return shieldsMock.getIsShieldsDownExport(); @@ -224,7 +275,9 @@ vi.mock("../../state/gateway", () => ({ })); vi.mock("../../state/registry", () => ({ + getConfiguredMessagingChannelsFromEntry: vi.fn(() => []), getCustomPolicies: getCustomPoliciesMock, + getDisabledMessagingChannelsFromEntry: vi.fn(() => []), getSandbox: getSandboxMock, listSandboxes: () => ({ sandboxes: ["alpha", "beta", "gamma"].map((name) => getSandboxMock(name)).filter(Boolean), @@ -276,6 +329,22 @@ export function resetSnapshotRestoreMocks(): void { getSandboxMock.mockReturnValue(null); isGatewayHealthyMock.mockReturnValue(true); listBackupsMock.mockReturnValue([]); + loadAgentMock.mockImplementation((name: string) => ({ + name, + policyAdditionsPath: name === "openclaw" ? null : `/repo/agents/${name}/policy-additions.yaml`, + })); + resolveAgentBaselinePolicyMock.mockImplementation((agent: string) => ({ + agent, + policyPath: + agent === "openclaw" + ? "/repo/nemoclaw-blueprint/policies/openclaw-sandbox.yaml" + : `/repo/agents/${agent}/policy-additions.yaml`, + content: "version: 1\nnetwork_policies: {}\n", + })); + prepareInitialSandboxCreatePolicyMock.mockImplementation((policyPath: string) => ({ + policyPath, + appliedPresets: [], + })); registerSandboxMock.mockReset(); updateSandboxMock.mockReset(); restoreSandboxStateMock.mockReturnValue({ @@ -285,6 +354,12 @@ export function resetSnapshotRestoreMocks(): void { failedDirs: [], failedFiles: [], }); + streamSandboxCreateMock.mockImplementation(async () => ({ + status: 0, + output: "", + sawProgress: false, + forcedReady: false, + })); parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); } diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts index 80eebe7e7b6..97b37d0fae6 100644 --- a/src/lib/actions/sandbox/snapshot.test.ts +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -5,6 +5,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveTestAgentBaselinePolicy } from "../../../../test/support/snapshot-policy-test-fixture"; +import { + type DcodeProbeState, + dcodeProbeOutput, + framedDcodeProbeOutput, +} from "./dcode-probe-test-fixture"; import { SANDBOX_EXEC_STARTED_MARKER } from "./sandbox-exec-output"; import type { SnapshotStreamSandboxCreateMock } from "./snapshot-create-stream-test-types"; @@ -17,15 +23,6 @@ type OpenshellCaptureResult = { signal?: NodeJS.Signals | null; }; type SandboxRecord = { name: string; observabilityEnabled?: boolean } & Record; -type DcodeProbeState = "active" | "idle" | "unverifiable" | "no-runtime"; - -function dcodeProbeOutput(state: DcodeProbeState, extra = ""): string { - return `${SANDBOX_EXEC_STARTED_MARKER}\nNEMOCLAW_DCODE_PROBE=${state}\n${extra}`; -} - -function framedDcodeProbeOutput(state: DcodeProbeState, framePrefix = "stdout: "): string { - return `${framePrefix}${SANDBOX_EXEC_STARTED_MARKER}\n${framePrefix}NEMOCLAW_DCODE_PROBE=${state}\n`; -} function captureOpenshellStreams( args: string[], @@ -169,13 +166,15 @@ vi.mock("../../inference/nim", () => ({ stopNimContainerByName: vi.fn(), })); -vi.mock("../../policy", () => ({ +vi.mock("../../policy", async (importOriginal) => ({ + ...(await importOriginal()), applyPreset: applyPresetMock, applyPresetContent: applyPresetContentMock, getAppliedPresets: getAppliedPresetsMock, getPresetContentGatewayState: getPresetContentGatewayStateMock, loadPresetForSandbox: loadPresetForSandboxMock, removePreset: removePresetMock, + resolveAgentBaselinePolicy: resolveTestAgentBaselinePolicy, })); vi.mock("../../runner", () => ({ @@ -217,6 +216,7 @@ vi.mock("../../state/gateway", () => ({ })); vi.mock("../../state/registry", () => ({ + getBaselineExclusions: vi.fn(() => []), getCustomPolicies: getCustomPoliciesMock, getSandbox: getSandboxMock, listSandboxes: () => ({ diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 2112ff36e5f..447fcd9ba14 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -72,6 +72,7 @@ import { selectSandboxGatewayIfRegistered, usesGatewayMetadataProbe, } from "./sandbox-gateway-routing"; +import { formatSnapshotBaselineExclusionSummary } from "./snapshot-baseline-exclusion-summary"; import { printHermesGatewayRestoreHint } from "./snapshot-hermes-gateway-hint"; const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; @@ -274,6 +275,35 @@ function resolveCloneDashboardEnvArgs( return envArgs; } +async function prepareSnapshotClonePolicy(srcEntry: SandboxEntry): Promise<{ + policyPath: string; + cleanup?: () => boolean; +}> { + if (srcEntry.baselineExclusionTransition) { + const transition = srcEntry.baselineExclusionTransition; + throw new Error( + `Cannot clone baseline policy while '${transition.operation} ${transition.exclusion.key}' needs repair. Re-run that policy command on '${srcEntry.name}' first.`, + ); + } + const agentName = srcEntry.agent || "openclaw"; + const baseline = policies.resolveAgentBaselinePolicy(agentName); + if (!baseline) { + throw new Error(`Cannot resolve the '${agentName}' baseline policy for snapshot restore.`); + } + const baselineExclusions = srcEntry.baselineExclusions ?? []; + if (baselineExclusions.length === 0) return { policyPath: baseline.policyPath }; + + const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(srcEntry)); + const activeMessagingChannels = registry + .getConfiguredMessagingChannelsFromEntry(srcEntry) + .filter((channel) => !disabledChannels.has(channel)); + const { prepareInitialSandboxCreatePolicy } = await import("../../onboard/initial-policy"); + return prepareInitialSandboxCreatePolicy(baseline.policyPath, activeMessagingChannels, { + agentName, + baselineExclusions, + }); +} + // Used by `snapshot restore --to ` when dst does not exist yet: reuses // the source's baked image so the user does not have to re-run onboarding. // Returns true on success; on failure, logs and throws SnapshotCommandError. @@ -282,10 +312,10 @@ async function autoCreateSandboxFromSource( dstName: string, srcEntry: SandboxEntry | { name: string }, fromImage: string, + createPolicyPath: string, dstDashboardPort: number | null, dashboardEnvArgs: readonly string[], ): Promise { - const basePolicy = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); const openshellBin = getOpenshellBinary(); const sourceObservabilityEnabled = (srcEntry as { observabilityEnabled?: boolean }).observabilityEnabled === true; @@ -307,7 +337,7 @@ async function autoCreateSandboxFromSource( "--from", fromImage, "--policy", - basePolicy, + createPolicyPath, "--auto-providers", "--", ...startupCommand, @@ -595,6 +625,11 @@ function runSnapshotCreate( const itemSummary = `${result.backedUpDirs.length} directories, ${result.backedUpFiles.length} files`; console.log(` ${G}✓${R} Snapshot ${v}${nameSuffix} created (${itemSummary})`); console.log(` ${manifest.backupPath}`); + for (const line of formatSnapshotBaselineExclusionSummary( + registry.getBaselineExclusions(sandboxName), + )) { + console.log(` ${line}`); + } return; } if (result.error) { @@ -861,9 +896,13 @@ async function runSnapshotRestore( const target = request.to ?? sandboxName; const targetSandbox = target === sandboxName ? sandboxName : validateName(target, "target sandbox name"); - return withSandboxMutationLock(targetSandbox, () => - runSnapshotRestoreUnlocked(sandboxName, request, targetSandbox), - ); + const lockNames = targetSandbox === sandboxName ? [sandboxName] : [sandboxName, targetSandbox]; + const orderedNames = [...new Set(lockNames)].sort(); + const acquire = (index: number): Promise => + index === orderedNames.length + ? runSnapshotRestoreUnlocked(sandboxName, request, targetSandbox) + : withSandboxMutationLock(orderedNames[index], () => acquire(index + 1)); + return acquire(0); } async function runSnapshotRestoreUnlocked( @@ -879,6 +918,16 @@ async function runSnapshotRestoreUnlocked( let crossSandboxRestoreAgent: string | null = null; const targetEntry = isCrossSandboxRestore ? registry.getSandbox(targetSandbox) : null; const targetExists = sourceLiveNames.has(targetSandbox) || Boolean(targetEntry); + if (targetEntry?.baselineExclusionTransition) { + const transition = targetEntry.baselineExclusionTransition; + console.error( + ` Cannot replace destination '${targetSandbox}' while baseline policy '${transition.operation} ${transition.exclusion.key}' needs repair.`, + ); + console.error( + ` Re-run that policy command on '${targetSandbox}' before restoring into it with --force.`, + ); + snapshotExit(1); + } // #3756 P1 preflight: resolve the snapshot selector AND the source pod // image before any destructive action. A bad selector, missing snapshot, @@ -1032,28 +1081,35 @@ async function runSnapshotRestoreUnlocked( // validation the image and gateway-route checks above already do (#3756). const dstDashboardPort = allocateCloneDashboardPort(targetSandbox, lockedSourceEntry); const dashboardEnvArgs = resolveCloneDashboardEnvArgs(lockedSourceEntry, dstDashboardPort); - if (targetExists) { - if (targetEntry) { - verifyRestoreDestinationOnOwnGateway(targetSandbox); + const clonePolicy = await prepareSnapshotClonePolicy(lockedSourceEntry); + try { + if (targetExists) { + if (targetEntry) { + verifyRestoreDestinationOnOwnGateway(targetSandbox); + } + deleteSandboxForRestore(targetSandbox); + requireLiveSandboxesOnSandboxGateway( + sandboxName, + " Failed to re-select source sandbox gateway after deleting destination.", + ); } - deleteSandboxForRestore(targetSandbox); - requireLiveSandboxesOnSandboxGateway( + await autoCreateSandboxFromSource( sandboxName, - " Failed to re-select source sandbox gateway after deleting destination.", + targetSandbox, + lockedSourceEntry, + lockedFromImage, + clonePolicy.policyPath, + dstDashboardPort, + dashboardEnvArgs, ); + } finally { + clonePolicy.cleanup?.(); } - await autoCreateSandboxFromSource( - sandboxName, - targetSandbox, - lockedSourceEntry, - lockedFromImage, - dstDashboardPort, - dashboardEnvArgs, - ); }; - // Lock order matches onboard: sandbox (outer caller), host dashboard, - // gateway route. The host-wide lease stays held from port selection until - // the clone is durably registered, including across different gateways. + // Lock order is both sandbox names (sorted by the outer caller), host + // dashboard, then gateway route. The host-wide lease stays held from port + // selection until the clone is durably registered, including across + // different gateways. await withDashboardPortReservationLock(() => withGatewayRouteMutationLock(sourceGatewayName, createAndRegisterClone), ); @@ -1111,7 +1167,7 @@ async function runSnapshotRestoreUnlocked( }); if (isCrossSandboxRestore && crossSandboxRestoreAgent === "openclaw") { try { - establishRestoredSandboxGatewayPairing(targetSandbox); + await establishRestoredSandboxGatewayPairing(targetSandbox); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new SnapshotCommandError([ diff --git a/src/lib/actions/sandbox/status-flow.test.ts b/src/lib/actions/sandbox/status-flow.test.ts index 19278cc8fb7..cc061391ab2 100644 --- a/src/lib/actions/sandbox/status-flow.test.ts +++ b/src/lib/actions/sandbox/status-flow.test.ts @@ -130,6 +130,64 @@ describe("showSandboxStatus flow", () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it("reports active baseline exclusions and their support impact (#7178)", async () => { + const harness = createStatusFlowHarness({ + sandboxEntry: { + baselineExclusions: [ + { version: 1, agent: "openclaw", key: "nous_research", digest: "digest" }, + ], + }, + }); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + + const output = harness.logSpy.mock.calls.flat().join("\n"); + expect(output).toContain("Baseline exclusions: nous_research"); + expect(output).toContain("Support impact:"); + expect(output).toContain("unsupported"); + expect(output).toContain("policy restore "); + }); + + it("warns when a recorded exclusion is still present in the live policy (#7178)", async () => { + const harness = createStatusFlowHarness({ + baselineExclusionStatus: "live-policy-mismatch", + sandboxEntry: { + baselineExclusions: [{ version: 1, agent: "openclaw", key: "pypi", digest: "digest" }], + }, + }); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + + const output = harness.logSpy.mock.calls.flat().join("\n"); + expect(output).toContain("pypi: excluded key is present in live policy"); + }); + + it("reports interrupted baseline policy repair and the exact reconciliation command (#7178)", async () => { + const harness = createStatusFlowHarness({ + sandboxEntry: { + baselineExclusionTransition: { + id: "tx-1", + operation: "restore", + exclusion: { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "digest", + }, + targetLiveDigest: "current-digest", + startedAt: "2026-07-19T00:00:00.000Z", + }, + }, + }); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + + const output = harness.logSpy.mock.calls.flat().join("\n"); + expect(output).toContain("Baseline policy repair required: interrupted restore"); + expect(output).toContain("rebuild blocked"); + expect(output).toContain("nemoclaw alpha policy restore nous_research"); + }); + it("omits serving-process status when the gateway is unavailable (#7003)", async () => { const harness = createStatusFlowHarness({ lookupState: "missing", diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index f8b7fd1fced..6363e0dd99d 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -24,6 +24,8 @@ import { normalizeDcodeAutoApprovalMode, } from "../../onboard/dcode-auto-approval"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; +import { getBaselineExclusionRuntimeStatus } from "../../policy"; +import type { BaselineExclusionRuntimeStatus } from "../../policy/baseline-exclusion"; import { redact } from "../../security/redact"; import { parseSandboxPhase } from "../../state/gateway"; import * as registry from "../../state/registry"; @@ -165,6 +167,15 @@ export interface SandboxStatusReport { openshellDriver: string; openshellVersion: string; policies: string[]; + /** Baseline network policy keys the operator has excluded, replayed on rebuild. */ + baselineExclusions: string[]; + /** Observed enforcement state for each recorded baseline exclusion. */ + baselineExclusionStates: Array<{ key: string; status: BaselineExclusionRuntimeStatus }>; + /** Interrupted cross-system policy mutation that must be reconciled before rebuild. */ + baselineExclusionTransition: { + operation: registry.BaselineExclusionTransitionOperation; + key: string; + } | null; failureLayer: SandboxStatusFailureLayer | null; terminalRuntimeHealth: TerminalRuntimeOomProbeResult | null; /** @@ -254,6 +265,7 @@ interface CollectSandboxStatusSnapshotDeps { reportInferenceProbeError?: (message: string) => void; probeTerminalRuntimeHealth?: ProbeTerminalRuntimeHealth; reconcile?: ReconcileSandboxGatewayState; + getBaselineExclusionRuntimeStatus?: typeof getBaselineExclusionRuntimeStatus; } function reportInferenceProbeError(error: unknown, writer: (message: string) => void): void { @@ -465,6 +477,21 @@ async function buildSandboxStatusReport( sb && Array.isArray(sb.policies) ? sb.policies.filter((policy): policy is string => typeof policy === "string") : []; + const baselineExclusions = sb?.baselineExclusions?.map((exclusion) => exclusion.key) ?? []; + const baselineExclusionStates = + sb?.baselineExclusions?.map((exclusion) => ({ + key: exclusion.key, + status: (deps.getBaselineExclusionRuntimeStatus ?? getBaselineExclusionRuntimeStatus)( + sandboxName, + exclusion, + ), + })) ?? []; + const baselineExclusionTransition = sb?.baselineExclusionTransition + ? { + operation: sb.baselineExclusionTransition.operation, + key: sb.baselineExclusionTransition.exclusion.key, + } + : null; const agent = resolveSandboxStatusAgent(sb?.agent || "openclaw"); return { schemaVersion: 1, @@ -496,6 +523,9 @@ async function buildSandboxStatusReport( openshellDriver: (sb && sb.openshellDriver) || "unknown", openshellVersion: (sb && sb.openshellVersion) || "unknown", policies, + baselineExclusions, + baselineExclusionStates, + baselineExclusionTransition, failureLayer: effectivePreflight.failureLayer, terminalRuntimeHealth, dockerPaused: !!dockerRuntime?.paused, diff --git a/src/lib/actions/sandbox/status-text.ts b/src/lib/actions/sandbox/status-text.ts index 42f2e12168c..d85240bb3d2 100644 --- a/src/lib/actions/sandbox/status-text.ts +++ b/src/lib/actions/sandbox/status-text.ts @@ -9,6 +9,11 @@ import { shellQuote } from "../../core/shell-quote"; import { formatInferenceRouteDriftForDisplay } from "../../inference/config"; import type { ProviderHealthStatus } from "../../inference/health"; import * as nim from "../../inference/nim"; +import { getBaselineExclusionRuntimeStatus } from "../../policy"; +import { + BASELINE_EXCLUSION_SUPPORT_IMPACT, + type BaselineExclusionRuntimeStatus, +} from "../../policy/baseline-exclusion"; import * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; import type { SandboxEntry, SandboxGpuProofResult } from "../../state/registry"; @@ -48,6 +53,42 @@ export interface SandboxStatusTextOutcome { exitCode: number | null; } +function describeBaselineExclusionStatus(status: BaselineExclusionRuntimeStatus): string { + switch (status) { + case "excluded": + return "live policy verified"; + case "agent-changed": + return "approval belongs to another agent"; + case "baseline-unreadable": + return "agent baseline unreadable"; + case "content-changed": + return "baseline content changed"; + case "no-longer-in-baseline": + return "key no longer in baseline"; + case "live-policy-unreadable": + return "live policy unreadable"; + case "live-policy-mismatch": + return "excluded key is present in live policy"; + } +} + +function printBaselineExclusions(sandboxName: string, sandbox: SandboxEntry): void { + if (!sandbox.baselineExclusions?.length) return; + console.log( + ` Baseline exclusions: ${sandbox.baselineExclusions.map((entry) => entry.key).join(", ")}`, + ); + console.log(` Support impact: ${BASELINE_EXCLUSION_SUPPORT_IMPACT}`); + console.log( + ` Review or restore with \`${CLI_NAME} ${sandboxName} policy list\` or \`${CLI_NAME} ${sandboxName} policy restore \`.`, + ); + for (const exclusion of sandbox.baselineExclusions) { + const status = getBaselineExclusionRuntimeStatus(sandboxName, exclusion); + if (status !== "excluded") { + console.log(` ${YW}${exclusion.key}: ${describeBaselineExclusionStatus(status)}${R}`); + } + } +} + /** Returns true when status can validate an agent version against the running sandbox. */ function shouldProbeSandboxRuntimeVersion( lookup: SandboxGatewayState, @@ -311,6 +352,16 @@ export function printSandboxDetails(context: SandboxStatusTextContext): SandboxS ` OpenShell: ${sb.openshellVersion || "unknown"} (${sb.openshellDriver || "unknown"})`, ); console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); + printBaselineExclusions(sandboxName, sb); + if (sb.baselineExclusionTransition) { + const transition = sb.baselineExclusionTransition; + console.log( + ` Baseline policy repair required: interrupted ${transition.operation} for ${transition.exclusion.key} (rebuild blocked)`, + ); + console.log( + ` Re-run \`${CLI_NAME} ${sandboxName} policy ${transition.operation} ${transition.exclusion.key}\` to reconcile live and durable state.`, + ); + } const agentExitCode = printAgentHarness(context); printActiveSessions(sandboxName); printShieldsPosture(sandboxName); diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 9b16d38ecf3..016dc59f92b 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -10,6 +10,7 @@ import { AGENTS_DIR, getAgentChoices, loadAgent, + requireAgentPolicyAdditionsPath, resolveAgentName, resolveAgentNameAlias, } from "./defs"; @@ -41,6 +42,26 @@ describe("agent definitions", () => { expect(choices.map((choice) => choice.name)).toContain("hermes"); }); + it("requires a readable regular policy-additions file for non-OpenClaw baselines (#7194)", () => { + const agentName = `missing-baseline-${String(Date.now())}`; + writeTempAgentManifest(agentName, `name: ${agentName}\ndisplay_name: Missing Baseline\n`); + const agent = loadAgent(agentName); + const policyPath = path.join(AGENTS_DIR, agentName, "policy-additions.yaml"); + + expect(() => requireAgentPolicyAdditionsPath(agent)).toThrow( + "Refusing to substitute the OpenClaw baseline", + ); + + fs.mkdirSync(policyPath); + expect(() => requireAgentPolicyAdditionsPath(agent)).toThrow( + "Refusing to substitute the OpenClaw baseline", + ); + fs.rmSync(policyPath, { recursive: true }); + fs.writeFileSync(policyPath, "version: 1\nnetwork_policies: {}\n"); + + expect(requireAgentPolicyAdditionsPath(agent)).toBe(policyPath); + }); + it("falls back to openclaw when session references an unknown agent", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index c7d93db37cf..02d66401598 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -109,6 +109,22 @@ export function listAgents(): string[] { .sort(); } +/** Resolve a non-OpenClaw agent's required, readable baseline policy. */ +export function requireAgentPolicyAdditionsPath( + agent: Pick, +): string { + const policyPath = agent.policyAdditionsPath; + try { + if (!policyPath || !fs.statSync(policyPath).isFile()) throw new Error("missing policy file"); + fs.accessSync(policyPath, fs.constants.R_OK); + return policyPath; + } catch { + throw new Error( + `Agent '${agent.name}' baseline policy is unavailable; a readable policy-additions.yaml is required. Refusing to substitute the OpenClaw baseline.`, + ); + } +} + /** * Load and parse an agent manifest. */ diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 275823b6b4f..d6bcbfa29a0 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -16,7 +16,13 @@ import { redact, run } from "../runner"; import * as baseImage from "./base-image"; import { describeAgentBinaryFailure, verifyAgentBinaryAvailable } from "./binary-availability"; import { printOptionalDashboardUi } from "./dashboard-ui"; -import { type AgentDefinition, isTerminalAgent, loadAgent, resolveAgentName } from "./defs"; +import { + type AgentDefinition, + isTerminalAgent, + loadAgent, + requireAgentPolicyAdditionsPath, + resolveAgentName, +} from "./defs"; import { runAgentSmokeCommands } from "./terminal-smoke"; import { enforceTerminalAgentVersion } from "./terminal-version-enforcement"; import { printBearerTokenApiAccess } from "./web-auth-ui"; @@ -127,7 +133,8 @@ export function resolveAgent({ * Get the agent-specific network policy path, or null to use the default. */ export function getAgentPolicyPath(agent: AgentDefinition): string | null { - return agent.policyAdditionsPath || null; + if (agent.name === "openclaw") return null; + return requireAgentPolicyAdditionsPath(agent); } /** diff --git a/src/lib/cli/command-registry.ts b/src/lib/cli/command-registry.ts index 519b266a867..e400dbedf73 100644 --- a/src/lib/cli/command-registry.ts +++ b/src/lib/cli/command-registry.ts @@ -18,7 +18,11 @@ import { CLI_DISPLAY_NAME, CLI_NAME } from "./branding"; import type { CommandGroup, PublicCommandDisplayEntry } from "./command-display"; import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata"; import { PUBLIC_DISPLAY_ENTRIES } from "./public-display-defaults"; -import { globalRouteTokenVariants, sandboxRouteTokens } from "./public-route-metadata"; +import { + globalRouteTokenVariants, + sandboxRouteTokens, + sandboxRouteTokenVariants, +} from "./public-route-metadata"; export type { CommandGroup } from "./command-display"; @@ -172,3 +176,27 @@ export function sandboxActionTokens(): string[] { } return tokens; } + +/** + * First-level sandbox action tokens for dispatch detection, including legacy + * hyphenated aliases (e.g. `policy-add`) alongside the canonical action tokens. + * The public grammar router uses this so a legacy spelling is still recognized + * as sandbox-first, while help and grouping use the canonical-only + * `sandboxActionTokens`. + */ +export function sandboxActionTokensForDispatch(): string[] { + const seen = new Set(); + const tokens: string[] = []; + for (const commandId of Object.keys(getRegisteredOclifCommandsMetadata())) { + for (const [token] of sandboxRouteTokenVariants(commandId)) { + if (token && !seen.has(token)) { + seen.add(token); + tokens.push(token); + } + } + } + if (!seen.has("")) { + tokens.push(""); + } + return tokens; +} diff --git a/src/lib/cli/public-argv-translation.ts b/src/lib/cli/public-argv-translation.ts index a4846f1a27f..822e8b860cc 100644 --- a/src/lib/cli/public-argv-translation.ts +++ b/src/lib/cli/public-argv-translation.ts @@ -5,7 +5,7 @@ import { getRegisteredOclifCommandMetadata, getRegisteredOclifCommandsMetadata, } from "./oclif-metadata"; -import { globalRouteTokenVariants, sandboxRouteTokens } from "./public-route-metadata"; +import { globalRouteTokenVariants, sandboxRouteTokenVariants } from "./public-route-metadata"; export type NativeArgvTranslation = { kind: "nativeArgv"; @@ -52,10 +52,9 @@ function sandboxRoutes(): SandboxRoute[] { return [...commandIds] .filter((commandId) => commandId.startsWith("sandbox:")) .filter((commandId) => !hasChildCommand(commandId, commandIds)) - .map((commandId) => ({ - commandId, - publicTokens: sandboxRouteTokens(commandId) ?? [], - })) + .flatMap((commandId) => + sandboxRouteTokenVariants(commandId).map((publicTokens) => ({ commandId, publicTokens })), + ) .filter((route) => route.publicTokens.length > 0) .sort((a, b) => b.publicTokens.length - a.publicTokens.length); } diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 1a63238742e..dd3e855672e 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -18,7 +18,7 @@ const { runOclifArgv, runOclifCommandById } = require("./oclif-runner"); const { canonicalUsageList, globalCommandTokens, - sandboxActionTokens, + sandboxActionTokensForDispatch, } = require("./command-registry"); import { migrateLegacyPortState } from "../state/legacy-port-migration"; @@ -145,7 +145,7 @@ function printConnectOrderHint(candidate: string | null): void { } function sandboxActionList(): string[] { - return sandboxActionTokens(); + return sandboxActionTokensForDispatch(); } type OpenShellCommandHint = { @@ -167,7 +167,7 @@ function getOpenShellCommandHint(argv: readonly string[]): OpenShellCommandHint return { entered: argv.join(" "), command: "openshell policy set --policy --wait ", - note: `For NemoClaw presets, use: ${CLI_NAME} policy-add `, + note: `For NemoClaw presets, use: ${CLI_NAME} policy add `, }; } if (cmd === "gateway" && subcommand === "stop") { diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 88fc9bbb75b..cc50558897c 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -386,6 +386,22 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { flags: "(--yes, -y, --dry-run)", }, ], + "sandbox:policy:exclude": [ + { + group: "Policy Presets", + order: 21, + description: "Exclude a baseline policy entry (persisted, replayed on rebuild)", + flags: "(--force, -f, --yes, -y, --dry-run)", + }, + ], + "sandbox:policy:restore": [ + { + group: "Policy Presets", + order: 22, + description: "Restore a previously excluded baseline entry", + flags: "(--dry-run)", + }, + ], "sandbox:rebuild": [ { group: "Sandbox Management", diff --git a/src/lib/cli/public-route-metadata.ts b/src/lib/cli/public-route-metadata.ts index 917d7c8d271..434062d8a2f 100644 --- a/src/lib/cli/public-route-metadata.ts +++ b/src/lib/cli/public-route-metadata.ts @@ -25,11 +25,19 @@ export const SANDBOX_ROUTE_OVERRIDES: Record = { "sandbox:hosts:add": ["hosts-add"], "sandbox:hosts:list": ["hosts-list"], "sandbox:hosts:remove": ["hosts-remove"], - "sandbox:policy:add": ["policy-add"], - "sandbox:policy:explain": ["policy-explain"], - "sandbox:policy:get": ["policy-get"], - "sandbox:policy:list": ["policy-list"], - "sandbox:policy:remove": ["policy-remove"], +}; + +// Legacy public spellings still accepted at parse time but no longer canonical. +// The `policy` group canonicalized to the two-token `policy ` grammar; +// the original hyphenated forms keep working so existing scripts and docs do +// not break, but help, completion, and display advertise only the canonical +// two-token form. +export const SANDBOX_LEGACY_ROUTE_ALIASES: Record = { + "sandbox:policy:add": [["policy-add"]], + "sandbox:policy:explain": [["policy-explain"]], + "sandbox:policy:get": [["policy-get"]], + "sandbox:policy:list": [["policy-list"]], + "sandbox:policy:remove": [["policy-remove"]], }; function commandIdTokens(commandId: string): string[] { @@ -56,3 +64,16 @@ export function sandboxRouteTokens(commandId: string): string[] | null { const tokens = commandIdTokens(commandId.slice("sandbox:".length)); return tokens.length > 0 ? tokens : null; } + +/** + * Every accepted public route for a sandbox command: the canonical route first, + * then any legacy spellings still parsed for backward compatibility. Argv + * translation matches all variants; display and completion use only the + * canonical route from `sandboxRouteTokens`. + */ +export function sandboxRouteTokenVariants(commandId: string): string[][] { + const canonical = sandboxRouteTokens(commandId); + if (!canonical) return []; + const legacy = SANDBOX_LEGACY_ROUTE_ALIASES[commandId] ?? []; + return [canonical, ...legacy.map((tokens) => [...tokens])]; +} diff --git a/src/lib/domain/policy-channel.ts b/src/lib/domain/policy-channel.ts index faa19fe61cd..5480368a1b1 100644 --- a/src/lib/domain/policy-channel.ts +++ b/src/lib/domain/policy-channel.ts @@ -30,6 +30,13 @@ export type PolicyRemoveOptions = { force?: boolean; }; +export type PolicyBaselineOptions = { + key?: string; + dryRun?: boolean; + yes?: boolean; + force?: boolean; +}; + function customPolicySourceFromOptions(options: PolicyAddOptions): CustomPolicySource { if (options.fromFile !== undefined && options.fromDir !== undefined) { return { kind: "error", message: "--from-file and --from-dir are mutually exclusive." }; diff --git a/src/lib/messaging/channels/telegram/hooks/status-health-eval.ts b/src/lib/messaging/channels/telegram/hooks/status-health-eval.ts index 4b16a832ce6..3e03bbc8070 100644 --- a/src/lib/messaging/channels/telegram/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/telegram/hooks/status-health-eval.ts @@ -116,7 +116,7 @@ function policyCoverageSignal(input: TelegramProbeInput): DiagnosticSignal { label: "Policy coverage", severity: "fail", detail: "telegram preset is not applied to the sandbox", - hint: "run `nemoclaw policy-add telegram` and rebuild the sandbox", + hint: "run `nemoclaw policy add telegram` and rebuild the sandbox", }; } if (input.presetOnGateway === null) { @@ -295,7 +295,7 @@ function buildHints(verdict: TelegramVerdict): string[] { "The telegram poller did not start. Check `nemoclaw logs --follow` and rebuild if needed.", ]; case "policy_gap": - return ["Run `nemoclaw policy-add telegram`, then rebuild."]; + return ["Run `nemoclaw policy add telegram`, then rebuild."]; case "config_gap": return ["Run `nemoclaw channels add telegram` to enable the channel."]; case "probe_failed": diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts index d597affec06..e7ccfea856e 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts @@ -281,7 +281,7 @@ function policyCoverageSignal(input: WhatsappProbeInput): DiagnosticSignal { label: "Policy coverage", severity: "fail", detail: "whatsapp preset is not applied to the sandbox", - hint: "run `nemoclaw policy-add whatsapp` and rebuild the sandbox", + hint: "run `nemoclaw policy add whatsapp` and rebuild the sandbox", }; } if (input.presetOnGateway === null) { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 810c92f2d45..b03aeb7cb71 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2251,7 +2251,7 @@ async function createSandboxWithBaseImageResolution( ? { extraProviders: createIntent.extraProviders, staleExtraProviders: [] } : planRegisteredExtraProviders(GATEWAY_NAME, { runOpenshell }); // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. - const resolvedCreateIntent = createIntent?.resolved ?? (await sandboxCreateIntentResolver.resolve({ sandboxName, inferenceProvider: provider, enabledChannels, webSearchConfig, agent, sandboxGpuConfig: effectiveSandboxGpuConfig, resourceProfile, hermesToolGateways, extraProviders: extraProviderPlan.extraProviders, staleExtraProviders: extraProviderPlan.staleExtraProviders, ...(createIntent?.reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), ...(createIntent?.policyTier !== undefined ? { policyTier: createIntent.policyTier } : {}) })); + const resolvedCreateIntent = createIntent?.resolved ?? (await sandboxCreateIntentResolver.resolve({ sandboxName, inferenceProvider: provider, enabledChannels, webSearchConfig, agent, sandboxGpuConfig: effectiveSandboxGpuConfig, resourceProfile, hermesToolGateways, extraProviders: extraProviderPlan.extraProviders, staleExtraProviders: extraProviderPlan.staleExtraProviders, baselineExclusions: sandboxRegistration.baselineExclusionsForCreate(sandboxName), ...(createIntent?.reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), ...(createIntent?.policyTier !== undefined ? { policyTier: createIntent.policyTier } : {}) })); const messagingCapabilities = await sandboxCreateIntentResolver.rebind( { sandboxName, @@ -2863,7 +2863,7 @@ async function createSandboxWithBaseImageResolution( ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), policyTier: resolvedCreatePolicyTier, // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. - ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod), dashboardRemoteBindPrepared), + ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod), dashboardRemoteBindPrepared, resolvedCreateIntent.policy.options.baselineExclusions), plannedMessagingState, preservedMcpState, hermesToolGateways, @@ -3887,7 +3887,6 @@ const recordInitialPreflightTransition = onboardRuntimeBoundary.recordInitialPreflightTransition.bind(onboardRuntimeBoundary); const recordPostVerifyStarted = onboardRuntimeBoundary.recordPostVerifyStarted.bind(onboardRuntimeBoundary); - /** Run only non-mutating fatal onboard gates while the rebuild target is still intact. */ async function preflightAuthoritativeRebuildTarget( opts: import("./onboard/authoritative-rebuild-target").AuthoritativeRebuildPreflightOptions, @@ -3912,6 +3911,7 @@ async function preflightAuthoritativeRebuildTarget( await authoritativeRebuildTarget.preflightAuthoritativeRebuildTarget( { ...opts, controlUiPort: opts.controlUiPort ?? null }, { + resolveBaselinePolicy: (sandboxName) => policies.resolveSandboxBaselinePolicy(sandboxName), runFatalRuntimePreflight: () => fatalRuntimePreflight.runFatalOnboardRuntimePreflight( { @@ -4359,9 +4359,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { repairLocalInferenceSystemdOverrideOrExit, isNonInteractive, getOpenshellBinary, - needsBedrockRuntimeAdapter: (providerName, url) => - providerName === "compatible-anthropic-endpoint" && - bedrockRuntimeOnboard.needsBedrockRuntimeAdapter(url), + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. + needsBedrockRuntimeAdapter: (providerName, url) => providerName === "compatible-anthropic-endpoint" && bedrockRuntimeOnboard.needsBedrockRuntimeAdapter(url), isInferenceRouteReady, isRoutedInferenceProvider, reconcileModelRouter, @@ -4427,7 +4426,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { getSandboxRegistryEntry: registry.getSandbox, normalizeHermesToolGatewaySelections, stringSetsEqual, - removeSandboxFromRegistry: registry.removeSandbox.bind(registry), + removeSandboxFromRegistry: registry.removeSandboxWithReceipt.bind(registry), + restoreSandboxRegistryEntryIfMissing: registry.restoreSandboxEntryIfMissing.bind(registry), repairRecordedSandbox, ensureValidatedWebSearchCredential, isBackToSelection, diff --git a/src/lib/onboard/authoritative-rebuild-target.test.ts b/src/lib/onboard/authoritative-rebuild-target.test.ts index c169a1d149e..9b7852686e9 100644 --- a/src/lib/onboard/authoritative-rebuild-target.test.ts +++ b/src/lib/onboard/authoritative-rebuild-target.test.ts @@ -25,6 +25,7 @@ const originalGateway = process.env.OPENSHELL_GATEWAY; function deps(overrides: Partial = {}) { return { + resolveBaselinePolicy: vi.fn(() => ({})), runFatalRuntimePreflight: vi.fn(), ensureOpenshell: vi.fn(), inferenceRouteReady: vi.fn(() => true), @@ -198,6 +199,18 @@ describe("prepared provider reconfiguration handoff", () => { }); describe("authoritative rebuild target preflight", () => { + it("rejects an unreadable replacement baseline before runtime probes (#7194)", async () => { + const targetDeps = deps({ resolveBaselinePolicy: vi.fn(() => null) }); + + await expect(preflightAuthoritativeRebuildTarget(target, targetDeps)).rejects.toThrow( + "Could not read the baseline policy", + ); + + expect(targetDeps.runFatalRuntimePreflight).not.toHaveBeenCalled(); + expect(targetDeps.ensureOpenshell).not.toHaveBeenCalled(); + expect(targetDeps.inferenceRouteReady).not.toHaveBeenCalled(); + }); + it("pins the requested gateway for route and forward checks, then restores it", async () => { process.env.OPENSHELL_GATEWAY = "before"; const seen: string[] = []; diff --git a/src/lib/onboard/authoritative-rebuild-target.ts b/src/lib/onboard/authoritative-rebuild-target.ts index b3e5cd6baba..558b4c40f13 100644 --- a/src/lib/onboard/authoritative-rebuild-target.ts +++ b/src/lib/onboard/authoritative-rebuild-target.ts @@ -176,6 +176,7 @@ export function rebuildProviderFlowOptions( } export type AuthoritativeRebuildTargetDeps = { + resolveBaselinePolicy(sandboxName: string): unknown | null; runFatalRuntimePreflight(): unknown; ensureOpenshell(): unknown; inferenceRouteReady(provider: string, model: string): boolean; @@ -196,6 +197,9 @@ export async function preflightAuthoritativeRebuildTarget( }; env.OPENSHELL_GATEWAY = target.targetGatewayName; try { + if (!deps.resolveBaselinePolicy(target.sandboxName)) { + fail(`Could not read the baseline policy for sandbox '${target.sandboxName}'.`); + } deps.runFatalRuntimePreflight(); deps.ensureOpenshell(); // Prepared-backup recovery can run after the installer has replaced a diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index 4976f64de1c..52469978a4a 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -548,7 +548,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa console.log( ` Model: ${deps.cliName()} inference set --model --provider --sandbox ${sandboxName}`, ); - console.log(` Policies: ${deps.cliName()} ${sandboxName} policy-add`); + console.log(` Policies: ${deps.cliName()} ${sandboxName} policy add`); console.log( ` Credentials: ${deps.cliName()} credentials reset && ${deps.cliName()} onboard`, ); diff --git a/src/lib/onboard/initial-policy-baseline-exclusion.test.ts b/src/lib/onboard/initial-policy-baseline-exclusion.test.ts new file mode 100644 index 00000000000..5140c9471b7 --- /dev/null +++ b/src/lib/onboard/initial-policy-baseline-exclusion.test.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import YAML from "yaml"; +import { resolveAgentBaselinePolicy } from "../policy"; +import { + BaselineExclusionDriftError, + digestBaselineEntry, + getBaselineEntry, +} from "../policy/baseline-exclusion"; +import { prepareInitialSandboxCreatePolicy } from "./initial-policy"; + +const BASE_POLICY = `version: 1 +network_policies: + nous_research: + name: nous_research + endpoints: + - host: nousresearch.com + port: 443 + protocol: rest + rules: + - allow: { method: GET, path: "/**" } + managed_inference: + name: managed_inference + endpoints: + - host: inference.local + port: 443 + protocol: rest + rules: + - allow: { method: POST, path: "/v1/**" } +`; + +const tempDirs: string[] = []; + +function writeBasePolicy(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-exclusion-test-")); + tempDirs.push(dir); + const filePath = path.join(dir, "policy-additions.yaml"); + fs.writeFileSync(filePath, BASE_POLICY); + return filePath; +} + +function digestOf(key: string): string { + const entry = getBaselineEntry(BASE_POLICY, key); + expect(entry).not.toBeNull(); + return digestBaselineEntry(entry!); +} + +function exclusion(key: string, digest: string, agent = "openclaw") { + return { version: 1 as const, agent, key, digest }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("prepareInitialSandboxCreatePolicy baseline exclusions (#7178)", () => { + it("drops an excluded entry from the generated policy", () => { + const basePath = writeBasePolicy(); + const result = prepareInitialSandboxCreatePolicy(basePath, [], { + agentName: "openclaw", + baselineExclusions: [exclusion("nous_research", digestOf("nous_research"))], + }); + const generated = YAML.parse(fs.readFileSync(result.policyPath, "utf-8")); + expect(Object.keys(generated.network_policies)).toEqual(["managed_inference"]); + result.cleanup?.(); + }); + + it("leaves the base policy untouched when no exclusions are requested", () => { + const basePath = writeBasePolicy(); + const result = prepareInitialSandboxCreatePolicy(basePath, [], {}); + expect(result.policyPath).toBe(basePath); + result.cleanup?.(); + }); + + it("fails closed when the recorded digest no longer matches the baseline", () => { + const basePath = writeBasePolicy(); + expect(() => + prepareInitialSandboxCreatePolicy(basePath, [], { + agentName: "openclaw", + baselineExclusions: [exclusion("nous_research", "stale-digest")], + }), + ).toThrowError(BaselineExclusionDriftError); + }); + + it("fails closed when the excluded entry was removed by a release", () => { + const basePath = writeBasePolicy(); + expect(() => + prepareInitialSandboxCreatePolicy(basePath, [], { + agentName: "openclaw", + baselineExclusions: [exclusion("removed_key", "any")], + }), + ).toThrowError(BaselineExclusionDriftError); + }); + + it("rejects the shipped Hermes pypi preset when pypi is excluded from its baseline (#7194)", () => { + const hermes = resolveAgentBaselinePolicy("hermes"); + expect(hermes).not.toBeNull(); + const entry = getBaselineEntry(hermes!.content, "pypi"); + expect(entry).not.toBeNull(); + + expect(() => + prepareInitialSandboxCreatePolicy(hermes!.policyPath, [], { + agentName: "hermes", + additionalPresets: ["pypi"], + baselineExclusions: [exclusion("pypi", digestBaselineEntry(entry!), "hermes")], + }), + ).toThrow(/network policy key 'pypi' is reserved by a baseline exclusion/); + }); +}); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index a9d91a2fff5..7a2a86e1657 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -8,6 +8,10 @@ import YAML from "yaml"; import { isObjectRecord } from "../core/json-types"; import { getMessagingPolicyKeysByChannel } from "../messaging/channels"; import * as policies from "../policy"; +import { + applyBaselineExclusions, + type BaselineExclusionRequest, +} from "../policy/baseline-exclusion"; import { allMessagingChannelPolicyPresets, requiredMessagingChannelPolicyPresets, @@ -331,6 +335,7 @@ export function prepareInitialSandboxCreatePolicy( additionalPresets?: string[]; agentName?: string | null; policyTier?: string | null; + baselineExclusions?: readonly BaselineExclusionRequest[]; } = {}, ): InitialSandboxPolicy { const directGpuPolicy = options.directGpu @@ -397,6 +402,25 @@ export function prepareInitialSandboxCreatePolicy( } } + // Replay operator baseline exclusions before presets merge on top. Fails + // closed via applyBaselineExclusions when a recorded approval no longer + // matches the current baseline, so a changed release forces re-review. + const baselineExclusions = options.baselineExclusions ?? []; + if (baselineExclusions.length > 0) { + const excluded = applyBaselineExclusions( + basePolicy, + baselineExclusions, + policyAgent ?? "openclaw", + ); + if (excluded.excludedKeys.length > 0) { + const policyPath = secureTempFile("nemoclaw-agent-policy", ".yaml"); + cleanupFns.push(createPolicyTempCleanup(policyPath, "nemoclaw-agent-policy")); + fs.writeFileSync(policyPath, excluded.content, { encoding: "utf-8", mode: 0o600 }); + effectiveBasePolicyPath = policyPath; + basePolicy = excluded.content; + } + } + const basePolicyNames = getNetworkPolicyNames(basePolicy); if (basePolicyNames === null) { return { @@ -433,6 +457,7 @@ export function prepareInitialSandboxCreatePolicy( const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets, { agent: policyAgent, + excludedBaselineKeys: baselineExclusions.map((exclusion) => exclusion.key), }); if (mergedPolicy.missingPresets.length > 0) { throw new Error( diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index a78e29bdda5..5fb55eb8f9c 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -192,7 +192,8 @@ function createPhases( normalizeHermesToolGatewaySelections: (value) => (Array.isArray(value) ? value : []), stringSetsEqual: (left, right) => left.length === right.length && left.every((item) => right.includes(item)), - removeSandboxFromRegistry: vi.fn(), + removeSandboxFromRegistry: vi.fn(() => null), + restoreSandboxRegistryEntryIfMissing: vi.fn(() => false), repairRecordedSandbox: vi.fn(), ensureValidatedWebSearchCredential: vi.fn(async () => null), isBackToSelection: () => false, @@ -231,6 +232,7 @@ function createPhases( directGpu: false, additionalPresets: [], policyTier: null, + baselineExclusions: [], }, }, gpuCreateArgs: [], diff --git a/src/lib/onboard/machine/handlers/sandbox-baseline-exclusion-lock.test.ts b/src/lib/onboard/machine/handlers/sandbox-baseline-exclusion-lock.test.ts new file mode 100644 index 00000000000..6ce13c0d034 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-baseline-exclusion-lock.test.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import * as registry from "../../../state/registry"; +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), +})); + +describe("sandbox create baseline exclusion locking (#7194)", () => { + it("resolves the complete create intent only after acquiring the sandbox mutation lock", async () => { + let lockHeld = false; + const { deps, calls } = createDeps({ + withSandboxMutationLock: async (_sandboxName, action) => { + expect(lockHeld).toBe(false); + lockHeld = true; + try { + return await action(); + } finally { + lockHeld = false; + } + }, + }); + const resolveCreateIntent = calls.resolveCreateIntent.getMockImplementation(); + const createSandbox = calls.createSandbox.getMockImplementation(); + calls.resolveCreateIntent.mockImplementation(async (...args) => { + expect(lockHeld).toBe(true); + return await resolveCreateIntent!(...args); + }); + calls.createSandbox.mockImplementation(async (...args) => { + expect(lockHeld).toBe(true); + return await createSandbox!(...args); + }); + + await handleSandboxState(baseOptions(deps)); + + expect(calls.resolveCreateIntent).toHaveBeenCalledOnce(); + expect(calls.createSandbox).toHaveBeenCalledOnce(); + expect(lockHeld).toBe(false); + }); + + it("rejects a transaction that appears while onboarding waits for its sandbox lock", async () => { + let lockHeld = false; + const transitionSpy = vi + .spyOn(registry, "getBaselineExclusionTransition") + .mockImplementation(() => + lockHeld + ? { + id: "00000000-0000-4000-8000-000000000001", + operation: "exclude", + exclusion: { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:00.000Z", + } + : null, + ); + try { + const { deps, calls } = createDeps({ + withSandboxMutationLock: async (_sandboxName, action) => { + lockHeld = true; + try { + return await action(); + } finally { + lockHeld = false; + } + }, + }); + + await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow( + "needs repair before sandbox creation", + ); + + expect(calls.resolveCreateIntent).not.toHaveBeenCalled(); + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).not.toHaveBeenCalled(); + } finally { + transitionSpy.mockRestore(); + } + }); + + it("rejects exclusion intent that changes before the destructive create edge", async () => { + const original = { + version: 1 as const, + agent: "openclaw", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: null, + }; + const changed = { ...original, digest: "b".repeat(64) }; + const transitionSpy = vi + .spyOn(registry, "getBaselineExclusionTransition") + .mockReturnValue(null); + const exclusionsSpy = vi + .spyOn(registry, "getBaselineExclusions") + .mockReturnValueOnce([original]) + .mockReturnValue([changed]); + try { + const { deps, calls } = createDeps({ + withSandboxMutationLock: async (_sandboxName, action) => await action(), + }); + + await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow( + "changed while sandbox creation was being prepared", + ); + + expect(calls.resolveCreateIntent).toHaveBeenCalledWith( + expect.objectContaining({ baselineExclusions: [original] }), + ); + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).not.toHaveBeenCalled(); + } finally { + exclusionsSpy.mockRestore(); + transitionSpy.mockRestore(); + } + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-baseline-exclusions.test.ts b/src/lib/onboard/machine/handlers/sandbox-baseline-exclusions.test.ts new file mode 100644 index 00000000000..4b55a2fb799 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-baseline-exclusions.test.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as registry from "../../../state/registry"; +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), +})); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("handleSandboxState baseline exclusions", () => { + it("carries complete records into the pre-destructive create intent", async () => { + const exclusion = { + version: 1 as const, + agent: "openclaw", + key: "nous_research", + digest: "abc", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: null, + }; + vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([exclusion]); + const { deps, calls } = createDeps(); + + await handleSandboxState(baseOptions(deps)); + + expect(calls.resolveCreateIntent).toHaveBeenCalledWith( + expect.objectContaining({ baselineExclusions: [exclusion] }), + ); + const createIntent = calls.createSandbox.mock.calls[0]?.at(-1) as unknown as { + resolved?: { policy?: { options?: { baselineExclusions?: unknown[] } } }; + }; + expect(createIntent.resolved?.policy?.options?.baselineExclusions).toEqual([exclusion]); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts b/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts index 96e6dbd1945..617a691538f 100644 --- a/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts @@ -86,6 +86,7 @@ describe("sandbox create intent machine boundary", () => { directGpu: false, additionalPresets: [], policyTier: null, + baselineExclusions: [], }, }, gpuCreateArgs: [], diff --git a/src/lib/onboard/machine/handlers/sandbox-destructive-resume-rollback.test.ts b/src/lib/onboard/machine/handlers/sandbox-destructive-resume-rollback.test.ts new file mode 100644 index 00000000000..0aa05470729 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-destructive-resume-rollback.test.ts @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { createSession, type Session } from "../../../state/onboard-session"; +import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), +})); + +vi.mocked(detectMessagingChannelsFromEnv).mockReturnValue([]); + +describe("handleSandboxState destructive resume rollback (#7194)", () => { + it("restores the removed registry row, including baseline exclusions, when replacement creation fails", async () => { + const session = createSession({ + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true }, + sandboxPromptProgress: { + sandboxName: true, + webSearch: true, + messaging: false, + resourceProfile: false, + }, + }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + agentSupportsWebSearch: () => false, + getSandboxReuseState: () => "ready", + updateSession: vi.fn( + (mutator: (value: Session) => Session | void) => mutator(session) ?? session, + ), + }); + const removalReceipt = { + entry: { + name: "saved", + baselineExclusions: [ + { version: 1 as const, agent: "openclaw", key: "nous_research", digest: "abc" }, + ], + }, + wasDefault: false, + fallbackDefault: null, + postRemovalDefaultSelectionRevision: 1, + }; + calls.removeSandbox.mockReturnValue(removalReceipt); + calls.createSandbox.mockRejectedValueOnce(new Error("openshell create failed")); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true }, + }), + ).rejects.toThrow("openshell create failed"); + + expect(calls.removeSandbox).toHaveBeenCalledWith("saved"); + expect(calls.restoreSandboxRegistryEntryIfMissing).toHaveBeenCalledWith(removalReceipt); + }); + + it("restores the removed row from the exit hook before process termination", async () => { + const session = createSession({ + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true }, + sandboxPromptProgress: { + sandboxName: true, + webSearch: true, + messaging: false, + resourceProfile: false, + }, + }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + agentSupportsWebSearch: () => false, + getSandboxReuseState: () => "ready", + updateSession: vi.fn( + (mutator: (value: Session) => Session | void) => mutator(session) ?? session, + ), + }); + const removalReceipt = { + entry: { + name: "saved", + baselineExclusions: [ + { version: 1 as const, agent: "openclaw", key: "nous_research", digest: "abc" }, + ], + }, + wasDefault: false, + fallbackDefault: null, + postRemovalDefaultSelectionRevision: 1, + }; + let exitListener: ((code: number) => void) | null = null; + const processOnce = vi.spyOn(process, "once").mockImplementation((( + event: string | symbol, + listener: (...args: unknown[]) => void, + ) => { + expect(event).toBe("exit"); + exitListener = listener as (code: number) => void; + return process; + }) as typeof process.once); + const processRemoveListener = vi + .spyOn(process, "removeListener") + .mockImplementation(() => process); + calls.removeSandbox.mockReturnValue(removalReceipt); + calls.createSandbox.mockImplementationOnce(async () => { + exitListener?.(1); + throw new Error("simulated process exit"); + }); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true }, + }), + ).rejects.toThrow("simulated process exit"); + + expect(processOnce).toHaveBeenCalledWith("exit", expect.any(Function)); + expect(calls.restoreSandboxRegistryEntryIfMissing).toHaveBeenCalledTimes(1); + expect(calls.restoreSandboxRegistryEntryIfMissing).toHaveBeenCalledWith(removalReceipt); + expect(processRemoveListener).toHaveBeenCalledWith("exit", expect.any(Function)); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts index 28fbc3c49b4..90c67fd26e4 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -1,11 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + applySandboxResumeDecision, decideSandboxResume, hasHermesCompatibleAnthropicInferenceRouteDrift, + type SandboxResumeDeps, type SandboxResumeSignals, } from "./sandbox-resume"; @@ -149,3 +151,55 @@ describe("decideSandboxResume", () => { ).toEqual({ kind: "create" }); }); }); + +function resumeDeps(overrides: Partial = {}): SandboxResumeDeps { + return { + note: vi.fn(), + removeSandboxFromRegistry: vi.fn(() => null), + repairRecordedSandbox: vi.fn(), + recordRepairEvent: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe("applySandboxResumeDecision (#7194)", () => { + it("returns the removal receipt so a failed replacement create can restore it", async () => { + const receipt = { + entry: { name: "saved" }, + wasDefault: false, + fallbackDefault: null, + postRemovalDefaultSelectionRevision: 1, + }; + const deps = resumeDeps({ removeSandboxFromRegistry: vi.fn(() => receipt) }); + + const result = await applySandboxResumeDecision( + { kind: "recreate", note: " recreating", removeRegistryEntry: true }, + "saved", + deps, + ); + + expect(deps.removeSandboxFromRegistry).toHaveBeenCalledWith("saved"); + expect(result).toBe(receipt); + }); + + it("does not remove the registry row or return a receipt when the decision keeps it", async () => { + const deps = resumeDeps(); + + const result = await applySandboxResumeDecision( + { kind: "recreate", note: " recreating", removeRegistryEntry: false }, + "saved", + deps, + ); + + expect(deps.removeSandboxFromRegistry).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("returns null for create and reuse decisions", async () => { + const deps = resumeDeps(); + + expect(await applySandboxResumeDecision({ kind: "create" }, "saved", deps)).toBeNull(); + expect(await applySandboxResumeDecision({ kind: "reuse" }, "saved", deps)).toBeNull(); + expect(deps.removeSandboxFromRegistry).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.ts b/src/lib/onboard/machine/handlers/sandbox-resume.ts index f6ff02d3c05..95cbb57c089 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.ts @@ -8,7 +8,7 @@ import { webSearchProviderForConfig, } from "../../../inference/web-search"; import type { Session } from "../../../state/onboard-session"; -import type { SandboxEntry } from "../../../state/registry"; +import type { SandboxEntry, SandboxRemovalReceipt } from "../../../state/registry"; import { normalizeToolDisclosure, toolDisclosureOrDefault } from "../../../tool-disclosure"; export interface SandboxResumeSignals { @@ -117,7 +117,7 @@ export function mcpRegistryRemovalBlockReason( export interface SandboxResumeDeps { note(message: string): void; - removeSandboxFromRegistry(sandboxName: string): void; + removeSandboxFromRegistry(sandboxName: string): SandboxRemovalReceipt | null; repairRecordedSandbox(sandboxName: string | null): void; recordRepairEvent( type: "state.repair.started" | "state.repair.completed" | "state.repair.failed", @@ -289,16 +289,23 @@ async function repairRecordedSandbox( await deps.recordRepairEvent("state.repair.completed", { state: "sandbox", metadata }); } +/** + * Apply a resume decision and return the removal receipt (if any) so the + * caller can restore the durable registry row, including its baseline + * exclusion records, when replacement creation then fails. + */ export async function applySandboxResumeDecision( decision: SandboxResumeDecision, sandboxName: string | null, deps: SandboxResumeDeps, -): Promise { +): Promise { if (decision.kind === "repair-and-recreate") { await repairRecordedSandbox(sandboxName, deps); - return; + return null; } - if (decision.kind !== "recreate") return; + if (decision.kind !== "recreate") return null; deps.note(decision.note); - if (decision.removeRegistryEntry && sandboxName) deps.removeSandboxFromRegistry(sandboxName); + if (decision.removeRegistryEntry && sandboxName) + return deps.removeSandboxFromRegistry(sandboxName); + return null; } diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index 7fa35726aa3..0ebf9e5874e 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -6,6 +6,7 @@ import { vi } from "vitest"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { CheckpointProviderBinding } from "../../../state/onboard-checkpoint-types"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import type { BaselineExclusionEntry, SandboxRemovalReceipt } from "../../../state/registry"; import type { SandboxStateOptions } from "./sandbox"; export function makeMinimalPlan( @@ -106,7 +107,8 @@ export function createDeps( }), persistMessaging: vi.fn(), clearPlanEnv: vi.fn(), - removeSandbox: vi.fn(), + removeSandbox: vi.fn((): SandboxRemovalReceipt | null => null), + restoreSandboxRegistryEntryIfMissing: vi.fn(() => false), repairSandbox: vi.fn(), validateBrave: vi.fn(async () => "brave-key"), isBackToSelection: vi.fn(() => false), @@ -129,6 +131,7 @@ export function createDeps( inferenceProvider?: string | null; extraProviders: readonly string[]; staleExtraProviders: readonly string[]; + baselineExclusions?: readonly BaselineExclusionEntry[]; }) => ({ sandboxName: input.sandboxName, inferenceProvider: input.inferenceProvider ?? null, @@ -145,6 +148,8 @@ export function createDeps( directGpu: false, additionalPresets: [], policyTier: null, + baselineExclusions: + input.baselineExclusions?.map((exclusion) => ({ ...exclusion })) ?? [], }, }, gpuCreateArgs: [], @@ -217,6 +222,7 @@ export function createDeps( stringSetsEqual: (left: string[], right: string[]) => left.length === right.length && left.every((value) => right.includes(value)), removeSandboxFromRegistry: calls.removeSandbox, + restoreSandboxRegistryEntryIfMissing: calls.restoreSandboxRegistryEntryIfMissing, repairRecordedSandbox: calls.repairSandbox, ensureValidatedWebSearchCredential: calls.validateBrave, isBackToSelection: calls.isBackToSelection, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index b262a2daaea..94350f596bf 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -36,7 +36,11 @@ import type { SessionResourceProfile, SessionUpdates, } from "../../../state/onboard-session"; -import type { SandboxEntry } from "../../../state/registry"; +import { + type BaselineExclusionEntry, + type SandboxEntry, + type SandboxRemovalReceipt, +} from "../../../state/registry"; import { getSandboxEntryInference } from "../../../state/registry-entry-view"; import { toolDisclosureOrDefault } from "../../../tool-disclosure"; import { @@ -74,6 +78,10 @@ import { isDcodeAgent, } from "../../observability-policy-presets"; import type { SandboxCreateIntent as ResolvedSandboxCreateIntent } from "../../sandbox-create-intent-types"; +import { + assertBaselineExclusionsMatchCreateIntent, + baselineExclusionsForCreate, +} from "../../sandbox-registration"; import { withSandboxPhaseTrace } from "../../tracing"; import type { SandboxCreateIntent } from "../../types"; import { branchTo, type OnboardStateTransitionResult } from "../result"; @@ -176,7 +184,8 @@ export interface SandboxStateOptions< getSandboxRegistryEntry(sandboxName: string): SandboxEntry | null; normalizeHermesToolGatewaySelections(value: unknown): string[]; stringSetsEqual(left: string[], right: string[]): boolean; - removeSandboxFromRegistry(sandboxName: string): void; + removeSandboxFromRegistry(sandboxName: string): SandboxRemovalReceipt | null; + restoreSandboxRegistryEntryIfMissing(receipt: SandboxRemovalReceipt): boolean; repairRecordedSandbox(sandboxName: string | null): void; ensureValidatedWebSearchCredential(config: WebSearchConfig): Promise; isBackToSelection(value: unknown): boolean; @@ -231,6 +240,7 @@ export interface SandboxStateOptions< extraProviders: readonly string[]; staleExtraProviders: readonly string[]; policyTier?: string | null; + baselineExclusions?: readonly BaselineExclusionEntry[]; reuseRegisteredCredentials?: boolean; }): Promise; createSandbox( @@ -1135,6 +1145,7 @@ class SandboxStateFlow< hermesToolGateways, extraProviders, staleExtraProviders, + baselineExclusions: baselineExclusionsForCreate(sandboxName), ...(reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), ...(this.options.authoritativePolicyTier !== undefined ? { policyTier: this.options.authoritativePolicyTier } @@ -1180,16 +1191,19 @@ class SandboxStateFlow< this.options.hermesToolGateways, ); const extraProviderPlan = this.deps.planRegisteredExtraProviders(this.options.gatewayName); - const createIntent = await this.buildSandboxCreateIntent( - state, - requestedSandboxName, - decision, - extraProviderPlan.extraProviders, - extraProviderPlan.staleExtraProviders, - resourceProfile, - effectiveHermesToolGateways, - ); const createAndRecord = async (): Promise> => { + // Build the complete create plan after acquiring the sandbox lock. A + // baseline transaction may have started while onboarding waited, and a + // pre-lock snapshot must never survive a destructive recreate. + const createIntent = await this.buildSandboxCreateIntent( + state, + requestedSandboxName, + decision, + extraProviderPlan.extraProviders, + extraProviderPlan.staleExtraProviders, + resourceProfile, + effectiveHermesToolGateways, + ); this.assertGatewayRouteCompatible(requestedSandboxName); this.assertCheckpointBindingsStillLive(state); this.assertCheckpointCreateInputsStillMatch( @@ -1206,37 +1220,67 @@ class SandboxStateFlow< current.messagingPlan = messagingPlan; return current; }); - await applySandboxResumeDecision(decision, state.sandboxName, this.deps); - if (this.options.fresh) { - this.deps.stopStaleDashboardListenersForSandbox( - this.deps.listRegistrySandboxes().sandboxes, + // Re-read at the destructive edge. The lock prevents cooperating + // writers from changing this state; the equality check also catches a + // direct registry writer that bypassed the lock. + assertBaselineExclusionsMatchCreateIntent( + requestedSandboxName, + createIntent.resolved.policy.options.baselineExclusions, + ); + const removalReceipt = await applySandboxResumeDecision( + decision, + state.sandboxName, + this.deps, + ); + let rollbackArmed = removalReceipt !== null; + const restoreRemovedRegistryEntry = () => { + if (!rollbackArmed || !removalReceipt) return; + rollbackArmed = false; + this.deps.restoreSandboxRegistryEntryIfMissing(removalReceipt); + }; + if (rollbackArmed) process.once("exit", restoreRemovedRegistryEntry); + + let sandboxName: string; + try { + if (this.options.fresh) { + this.deps.stopStaleDashboardListenersForSandbox( + this.deps.listRegistrySandboxes().sandboxes, + requestedSandboxName, + ); + } + sandboxName = await withSandboxPhaseTrace( requestedSandboxName, + this.options.provider, + this.options.model, + (this.options.agent as { name?: string } | null)?.name, + () => + this.deps.createSandbox( + this.options.gpu, + this.options.model, + this.options.provider, + this.options.preferredInferenceApi, + requestedSandboxName, + state.webSearchConfig, + state.selectedMessagingChannels, + this.options.fromDockerfile, + this.options.agent, + this.options.controlUiPort, + this.options.sandboxGpuConfig, + resourceProfile, + effectiveHermesToolGateways, + this.options.hermesAuthMethod, + createIntent, + ), ); + // createSandbox returns only after the replacement row is registered. + // From this point the receipt must not overwrite that newer entry. + rollbackArmed = false; + process.removeListener("exit", restoreRemovedRegistryEntry); + } catch (error) { + restoreRemovedRegistryEntry(); + process.removeListener("exit", restoreRemovedRegistryEntry); + throw error; } - const sandboxName = await withSandboxPhaseTrace( - requestedSandboxName, - this.options.provider, - this.options.model, - (this.options.agent as { name?: string } | null)?.name, - () => - this.deps.createSandbox( - this.options.gpu, - this.options.model, - this.options.provider, - this.options.preferredInferenceApi, - requestedSandboxName, - state.webSearchConfig, - state.selectedMessagingChannels, - this.options.fromDockerfile, - this.options.agent, - this.options.controlUiPort, - this.options.sandboxGpuConfig, - resourceProfile, - effectiveHermesToolGateways, - this.options.hermesAuthMethod, - createIntent, - ), - ); // createSandbox() owns the build fingerprint. In particular, reusing an // image must not stamp it with the current version and hide build drift. const { nemoclawVersion: _builtFingerprint, ...agentRegistryFields } = diff --git a/src/lib/onboard/policy-tier-suppression.ts b/src/lib/onboard/policy-tier-suppression.ts index 277dbda6a51..380a76eece5 100644 --- a/src/lib/onboard/policy-tier-suppression.ts +++ b/src/lib/onboard/policy-tier-suppression.ts @@ -117,7 +117,7 @@ export function emitSuppressedAgentRequiredPresetsNote( 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.`, + ` 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/sandbox-create-intent-resolution.ts b/src/lib/onboard/sandbox-create-intent-resolution.ts index 3bc70fe0524..33f9e28fe04 100644 --- a/src/lib/onboard/sandbox-create-intent-resolution.ts +++ b/src/lib/onboard/sandbox-create-intent-resolution.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../inference/web-search"; +import type { BaselineExclusionEntry } from "../state/registry"; import type { DockerGpuRoutePlan } from "./docker-gpu-route"; import type { NamedMessagingChannel } from "./messaging-prep"; import { @@ -30,6 +31,8 @@ export type CompleteSandboxCreateIntentInput = { extraProviders: readonly string[]; staleExtraProviders: readonly string[]; policyTier?: string | null; + /** Operator baseline exclusions replayed into create/rebuild policy generation. */ + baselineExclusions?: readonly BaselineExclusionEntry[]; /** Internal OpenClaw resume authority for exact registered provider reuse. */ reuseRegisteredCredentials?: boolean; }; @@ -139,6 +142,7 @@ export function createSandboxCreateIntentResolver< extraPlaceholderKeys: messaging.extraPlaceholderKeys, agentName: input.agent?.name, policyTier: resolveSandboxCreatePolicyTier(input.policyTier), + baselineExclusions: input.baselineExclusions, }); } diff --git a/src/lib/onboard/sandbox-create-intent-types.ts b/src/lib/onboard/sandbox-create-intent-types.ts index 592ef810b71..71d2cf1f5d9 100644 --- a/src/lib/onboard/sandbox-create-intent-types.ts +++ b/src/lib/onboard/sandbox-create-intent-types.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { BaselineExclusionEntry } from "../state/registry"; import type { DockerGpuRoutePlan } from "./docker-gpu-route"; import type { InitialSandboxPolicy } from "./initial-policy"; import type { MessagingTokenDef } from "./messaging-prep"; @@ -26,6 +27,7 @@ export type SandboxCreatePolicyRequest = { readonly additionalPresets: readonly string[]; readonly agentName?: string | null; readonly policyTier: string | null; + readonly baselineExclusions: readonly BaselineExclusionEntry[]; }; }; @@ -79,6 +81,7 @@ export type ResolveSandboxCreateIntentInput = { extraPlaceholderKeys?: readonly string[]; agentName?: string | null; policyTier: string | null; + baselineExclusions?: readonly BaselineExclusionEntry[]; }; export type MaterializeSandboxCreatePlanInput = { diff --git a/src/lib/onboard/sandbox-create-intent.ts b/src/lib/onboard/sandbox-create-intent.ts index 255dd573d66..21d07842be8 100644 --- a/src/lib/onboard/sandbox-create-intent.ts +++ b/src/lib/onboard/sandbox-create-intent.ts @@ -149,6 +149,7 @@ export function resolveSandboxCreateIntent({ extraPlaceholderKeys = [], agentName, policyTier, + baselineExclusions = [], }: ResolveSandboxCreateIntentInput): SandboxCreateIntent { const enabledMessagingProviderRequests = filterMessagingProviderRequestsByEnabledChannel( messagingProviderRequests, @@ -188,6 +189,7 @@ export function resolveSandboxCreateIntent({ additionalPresets: [...hermesToolGateways], ...(agentName !== undefined ? { agentName } : {}), policyTier, + baselineExclusions: [...baselineExclusions].map((exclusion) => ({ ...exclusion })), }, }, gpuCreateArgs: [...gpuCreateArgs], diff --git a/src/lib/onboard/sandbox-create-plan-materialization.ts b/src/lib/onboard/sandbox-create-plan-materialization.ts index 091fe246995..1f02d87da82 100644 --- a/src/lib/onboard/sandbox-create-plan-materialization.ts +++ b/src/lib/onboard/sandbox-create-plan-materialization.ts @@ -133,6 +133,9 @@ export function materializeSandboxCreatePlan({ additionalPresets: [...intent.policy.options.additionalPresets], agentName: intent.policy.options.agentName, policyTier: intent.policy.options.policyTier, + baselineExclusions: intent.policy.options.baselineExclusions.map((exclusion) => ({ + ...exclusion, + })), }, intent.gpuRoutePlan, prepareInitialSandboxCreatePolicy, diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts index adb964368c8..8a7ce86dffa 100644 --- a/src/lib/onboard/sandbox-create-plan.test.ts +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -169,6 +169,16 @@ describe("resolveSandboxCreateIntent", () => { extraPlaceholderKeys: ["TELEGRAM_BOT_TOKEN_AGENT_A"], agentName: "hermes", policyTier: "balanced", + baselineExclusions: [ + { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: "abc", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: null, + }, + ], }; const first = resolveSandboxCreateIntent(input); @@ -193,6 +203,16 @@ describe("resolveSandboxCreateIntent", () => { additionalPresets: ["github"], agentName: "hermes", policyTier: "balanced", + baselineExclusions: [ + { + version: 1, + agent: "hermes", + key: "nous_research", + digest: "abc", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: null, + }, + ], }, }); expect(JSON.parse(JSON.stringify(first))).toEqual(first); @@ -457,6 +477,7 @@ describe("prepareSandboxCreatePlan", () => { additionalPresets: ["github"], agentName: "langchain-deepagents-code", policyTier: "restricted", + baselineExclusions: [], }, ); expect(result.policyTier).toBe("restricted"); diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index aeea20e7d51..49a8da9ef76 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -6,9 +6,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const requireDist = createRequire(import.meta.url); const onboardSession = requireDist("../state/onboard-session.js"); -const { buildCreatedSandboxRegistryEntry, registerCreatedSandbox, selection } = requireDist( - "./sandbox-registration.ts", -) as typeof import("./sandbox-registration"); +const { + assertBaselineExclusionsMatchCreateIntent, + baselineExclusionsForCreate, + buildCreatedSandboxRegistryEntry, + creationFidelity, + registerCreatedSandbox, + selection, +} = requireDist("./sandbox-registration.ts") as typeof import("./sandbox-registration"); const runtimeFields = { gpuEnabled: true, @@ -21,6 +26,60 @@ const runtimeFields = { }; describe("buildCreatedSandboxRegistryEntry", () => { + it("blocks create intent while a baseline policy transaction needs repair (#7178)", () => { + const registry = requireDist("../state/registry.js"); + const transitionSpy = vi.spyOn(registry, "getBaselineExclusionTransition").mockReturnValue({ + id: "tx-1", + operation: "exclude", + exclusion: { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "approved", + }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:00.000Z", + }); + + expect(() => baselineExclusionsForCreate("alpha")).toThrow( + /policy exclude.*needs repair before sandbox creation/i, + ); + + transitionSpy.mockRestore(); + }); + + it("rejects a resolved create intent when durable baseline exclusions changed (#7194)", () => { + const registry = requireDist("../state/registry.js"); + const transitionSpy = vi + .spyOn(registry, "getBaselineExclusionTransition") + .mockReturnValue(null); + const exclusionsSpy = vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([ + { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "b".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + ]); + try { + expect(() => + assertBaselineExclusionsMatchCreateIntent("alpha", [ + { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + ]), + ).toThrow(/changed while sandbox creation was being prepared/i); + } finally { + exclusionsSpy.mockRestore(); + transitionSpy.mockRestore(); + } + }); + it("records the final created sandbox metadata with configured messaging channels", () => { const plannedMessagingState = { schemaVersion: 1 as const, @@ -213,6 +272,51 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.toolDisclosure).toBe("direct"); }); + it("carries complete baseline exclusion records through consecutive registrations", () => { + const baselineExclusions = [ + { + version: 1 as const, + agent: "openclaw", + key: "nous_research", + digest: "abc", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: null, + }, + ]; + const fidelity = creationFidelity(null, null, null, false, baselineExclusions); + const common = { + sandboxName: "demo", + inferenceSelection: { + model: "llama", + provider: "compatible-endpoint", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + compatibleEndpointReasoning: null, + nimContainer: null, + }, + runtimeFields, + agent: null, + agentVersionKnown: true, + imageTag: null, + appliedPolicies: [], + plannedMessagingState: undefined, + hermesToolGateways: [], + hermesDashboardState: { enabled: false as const, config: null }, + dashboardPort: 18789, + gatewayName: "nemoclaw", + gatewayPort: 8080, + }; + + const first = buildCreatedSandboxRegistryEntry({ ...common, ...fidelity }); + const secondFidelity = creationFidelity(null, null, null, false, first.baselineExclusions); + const second = buildCreatedSandboxRegistryEntry({ ...common, ...secondFidelity }); + + expect(second.baselineExclusions).toEqual(baselineExclusions); + expect(second.baselineExclusions).not.toBe(first.baselineExclusions); + expect(second.baselineExclusions?.[0]).not.toBe(first.baselineExclusions?.[0]); + }); + it("normalizes invalid preferred inference API values", () => { const entry = buildCreatedSandboxRegistryEntry({ sandboxName: "demo", diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index d0554b60859..2bfe110fba7 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -1,13 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isDeepStrictEqual } from "node:util"; + import type { AgentDefinition } from "../agent/defs"; import type { InferenceEndpointSource, InferenceSelection } from "../inference/selection"; import { inferenceSelectionRegistryFields } from "../inference/selection"; import { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search"; import * as onboardSession from "../state/onboard-session"; import type { OpenClawImagePluginInstall } from "../state/openclaw-plugin-restore"; -import type { SandboxEntry, SandboxMcpState, SandboxMessagingState } from "../state/registry"; +import type { + BaselineExclusionEntry, + SandboxEntry, + SandboxMcpState, + SandboxMessagingState, +} from "../state/registry"; import * as registry from "../state/registry"; import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; import type { DcodeAutoApprovalMode } from "./dcode-auto-approval"; @@ -42,6 +49,7 @@ export interface CreatedSandboxRegistryEntryInput { observabilityEnabled?: boolean; dcodeAutoApprovalMode?: DcodeAutoApprovalMode; policyTier?: SandboxEntry["policyTier"]; + baselineExclusions?: readonly BaselineExclusionEntry[]; webSearchEnabled?: boolean; webSearchProvider?: SandboxEntry["webSearchProvider"]; fromDockerfile?: string | null; @@ -69,6 +77,7 @@ export function creationFidelity( fromDockerfile: string | null, hermesAuthMethod: "oauth" | "api_key" | null, dashboardRemoteBindPrepared?: boolean, + baselineExclusions?: readonly BaselineExclusionEntry[], ): Pick< SandboxEntry, | "webSearchEnabled" @@ -76,6 +85,7 @@ export function creationFidelity( | "fromDockerfile" | "hermesAuthMethod" | "dashboardRemoteBindPrepared" + | "baselineExclusions" > { return { webSearchEnabled: webSearchConfig?.fetchEnabled === true, @@ -83,9 +93,41 @@ export function creationFidelity( fromDockerfile, hermesAuthMethod, dashboardRemoteBindPrepared: dashboardRemoteBindPrepared === true, + baselineExclusions: baselineExclusions?.map((exclusion) => ({ ...exclusion })), }; } +/** Snapshot complete exclusion records before a destructive create removes registry state. */ +export function baselineExclusionsForCreate(sandboxName: string): BaselineExclusionEntry[] { + const transition = registry.getBaselineExclusionTransition(sandboxName); + if (transition) { + const key = transition.exclusion.key; + throw new Error( + `Baseline policy ${transition.operation} for '${key}' needs repair before sandbox creation. Re-run 'policy ${transition.operation} ${key}' first.`, + ); + } + return registry.getBaselineExclusions(sandboxName).map((exclusion) => ({ ...exclusion })); +} + +/** + * Re-read exclusion intent at the destructive create edge and prove it still + * matches the already-resolved policy plan. The sandbox mutation lock is the + * caller's serialization boundary; this comparison catches stale plans and + * any direct registry writer that bypassed that lock. + */ +export function assertBaselineExclusionsMatchCreateIntent( + sandboxName: string, + planned: readonly BaselineExclusionEntry[], +): BaselineExclusionEntry[] { + const current = baselineExclusionsForCreate(sandboxName); + if (!isDeepStrictEqual(current, [...planned])) { + throw new Error( + `Baseline policy exclusions for '${sandboxName}' changed while sandbox creation was being prepared. Retry so the replacement policy uses current registry intent.`, + ); + } + return current; +} + export function selection( sandboxName: string, provider: string, @@ -135,6 +177,7 @@ export function buildCreatedSandboxRegistryEntry( } : {}), policies: input.appliedPolicies, + baselineExclusions: input.baselineExclusions?.map((exclusion) => ({ ...exclusion })), toolDisclosure: input.toolDisclosure ?? DEFAULT_TOOL_DISCLOSURE, observabilityEnabled: input.observabilityEnabled === true, ...(input.dcodeAutoApprovalMode !== undefined diff --git a/src/lib/onboard/tool-disclosure-flow.test.ts b/src/lib/onboard/tool-disclosure-flow.test.ts index 9f54818bdff..d5b8c80e596 100644 --- a/src/lib/onboard/tool-disclosure-flow.test.ts +++ b/src/lib/onboard/tool-disclosure-flow.test.ts @@ -153,6 +153,47 @@ describe("onboard tool-disclosure flow", () => { expect(mocks.removeSandbox).not.toHaveBeenCalled(); }); + it("keeps baseline-exclusion retry metadata when absent replacement creation fails (#7194)", () => { + const baselineExclusions = [ + { + version: 1 as const, + agent: "openclaw", + key: "openclaw_docs", + digest: "baseline-digest", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: "2026.6.10", + }, + ]; + const retryEntry = { + name: "alpha", + toolDisclosure: "progressive" as const, + baselineExclusions, + }; + let registryEntry: typeof retryEntry | null = retryEntry; + mocks.removeSandbox.mockImplementation(() => { + registryEntry = null; + }); + + expect(() => { + prepareSandboxToolDisclosure( + "alpha", + null, + true, + () => ({ + existingEntry: registryEntry, + preservedMcpState: undefined, + liveExists: false, + }), + "progressive", + ); + throw new Error("injected create failure"); + }).toThrow("injected create failure"); + + expect(registryEntry?.baselineExclusions).toEqual(baselineExclusions); + expect(mocks.updateSession).toHaveBeenCalledOnce(); + expect(mocks.removeSandbox).not.toHaveBeenCalled(); + }); + it("still clears a stale registry entry that has no live sandbox and no pending reservation", () => { prepareSandboxToolDisclosure( "beta", diff --git a/src/lib/onboard/tool-disclosure-flow.ts b/src/lib/onboard/tool-disclosure-flow.ts index 3250df319fc..e0658288d09 100644 --- a/src/lib/onboard/tool-disclosure-flow.ts +++ b/src/lib/onboard/tool-disclosure-flow.ts @@ -60,13 +60,14 @@ export function prepareSandboxToolDisclosure( } } - // Keep inspection and validation ahead of every mutation. Splitting these - // steps across lifecycle callbacks would require a transaction object to - // preserve this fail-closed ordering for registry and session state. + // Keep inspection and validation ahead of every mutation. MCP and baseline + // exclusions are registry-only rebuild intent: replacement registration + // overwrites the retained row, while a failed create leaves retry metadata. if ( existingEntry && !liveExists && !preservedMcpState && + (existingEntry.baselineExclusions?.length ?? 0) === 0 && existingEntry.pendingRouteReservation !== true ) { registry.removeSandbox(sandboxName); diff --git a/src/lib/policy/baseline-exclusion-journal-integration.test.ts b/src/lib/policy/baseline-exclusion-journal-integration.test.ts new file mode 100644 index 00000000000..d92c36c1455 --- /dev/null +++ b/src/lib/policy/baseline-exclusion-journal-integration.test.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const harness = vi.hoisted(() => ({ + livePolicy: "", + run: vi.fn(), + runCapture: vi.fn(), +})); + +vi.mock("../runner", async (importOriginal) => ({ + ...(await importOriginal()), + run: harness.run, + runCapture: harness.runCapture, +})); + +vi.mock("../adapters/openshell/resolve", async (importOriginal) => ({ + ...(await importOriginal()), + resolveOpenshell: vi.fn(() => "/usr/bin/openshell"), +})); + +const originalHome = process.env.HOME; +const temporaryHomes: string[] = []; + +afterEach(() => { + process.env.HOME = originalHome; + vi.restoreAllMocks(); + vi.resetModules(); + for (const home of temporaryHomes.splice(0)) { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +describe("baseline exclusion journal integration", () => { + it("reloads and finalizes a real persisted journal after interrupted commit (#7178)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-journal-")); + temporaryHomes.push(home); + process.env.HOME = home; + vi.resetModules(); + + const registry = await import("../state/registry"); + const baseline = await import("./baseline-exclusion"); + const policy = await import("./index"); + registry.registerSandbox({ + name: "alpha", + agent: "hermes", + gatewayName: "nemoclaw", + }); + + harness.livePolicy = `version: 1 +network_policies: + nous_research: + endpoints: + - host: nousresearch.com + port: 443 +`; + const entry = baseline.getBaselineEntry(harness.livePolicy, "nous_research"); + expect(entry).not.toBeNull(); + const digest = baseline.digestBaselineEntry(entry!); + harness.runCapture.mockImplementation(() => harness.livePolicy); + harness.run.mockImplementation((command: readonly string[]) => { + const policyIndex = command.indexOf("--policy"); + harness.livePolicy = fs.readFileSync(command[policyIndex + 1], "utf8"); + return { status: 0 }; + }); + const interruptedCommit = vi + .spyOn(registry, "commitBaselineExclusionTransition") + .mockReturnValueOnce(false); + + expect(policy.excludeBaselineEntry("alpha", "nous_research", digest, { nonFatal: true })).toBe( + false, + ); + expect(harness.livePolicy).not.toContain("nous_research:"); + expect(registry.getBaselineExclusionTransition("alpha")).toEqual( + expect.objectContaining({ + operation: "exclude", + exclusion: expect.objectContaining({ digest }), + }), + ); + expect(registry.getBaselineExclusions("alpha")).toEqual([]); + interruptedCommit.mockRestore(); + + // Simulate a new CLI process: reload both the registry and policy modules + // from the same temp HOME, then retry against the exact live target. + vi.resetModules(); + const reloadedRegistry = await import("../state/registry"); + const reloadedPolicy = await import("./index"); + expect(reloadedPolicy.excludeBaselineEntry("alpha", "nous_research", digest)).toBe(true); + expect(reloadedRegistry.getBaselineExclusionTransition("alpha")).toBeNull(); + expect(reloadedRegistry.getBaselineExclusions("alpha")).toEqual([ + expect.objectContaining({ key: "nous_research", digest }), + ]); + }); +}); diff --git a/src/lib/policy/baseline-exclusion-persistence.test.ts b/src/lib/policy/baseline-exclusion-persistence.test.ts new file mode 100644 index 00000000000..0595509fe53 --- /dev/null +++ b/src/lib/policy/baseline-exclusion-persistence.test.ts @@ -0,0 +1,620 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; + +const mocks = vi.hoisted(() => ({ + addBaselineExclusion: vi.fn(), + beginBaselineExclusionTransition: vi.fn(), + clearBaselineExclusionTransition: vi.fn(), + commitBaselineExclusionTransition: vi.fn(), + getBaselineExclusions: vi.fn(), + getBaselineExclusionTransition: vi.fn(), + getSandbox: vi.fn(), + removeBaselineExclusion: vi.fn(), + run: vi.fn(), + runCapture: vi.fn(), +})); + +vi.mock("../runner", async (importOriginal) => ({ + ...(await importOriginal()), + run: mocks.run, + runCapture: mocks.runCapture, +})); + +vi.mock("../state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + addBaselineExclusion: mocks.addBaselineExclusion, + beginBaselineExclusionTransition: mocks.beginBaselineExclusionTransition, + clearBaselineExclusionTransition: mocks.clearBaselineExclusionTransition, + commitBaselineExclusionTransition: mocks.commitBaselineExclusionTransition, + getBaselineExclusions: mocks.getBaselineExclusions, + getBaselineExclusionTransition: mocks.getBaselineExclusionTransition, + getSandbox: mocks.getSandbox, + removeBaselineExclusion: mocks.removeBaselineExclusion, +})); + +import * as openshellResolveModule from "../adapters/openshell/resolve"; +import { digestBaselineEntry, getBaselineEntry } from "./baseline-exclusion"; +import { + applyPresetContent, + excludeBaselineEntry, + getBaselineExclusionRuntimeStatus, + loadPresetForSandbox, + restoreBaselineEntry, +} from "./index"; + +const LIVE_POLICY = `version: 1 +network_policies: + nous_research: + endpoints: + - host: nousresearch.com + port: 443 +`; +const LIVE_ENTRY = getBaselineEntry(LIVE_POLICY, "nous_research"); +const LIVE_DIGEST = digestBaselineEntry(LIVE_ENTRY!); +const HERMES_BASELINE = fs.readFileSync("agents/hermes/policy-additions.yaml", "utf8"); +const HERMES_BASELINE_ENTRY = getBaselineEntry(HERMES_BASELINE, "nous_research"); +const HERMES_BASELINE_DIGEST = digestBaselineEntry(HERMES_BASELINE_ENTRY!); +const HERMES_MANAGED_INFERENCE_DIGEST = digestBaselineEntry( + getBaselineEntry(HERMES_BASELINE, "managed_inference")!, +); +const HERMES_RESTORED_POLICY = YAML.stringify({ + version: 1, + network_policies: { nous_research: HERMES_BASELINE_ENTRY }, +}); +const OPENCLAW_BASELINE_ENTRY = getBaselineEntry( + fs.readFileSync("nemoclaw-blueprint/policies/openclaw-sandbox.yaml", "utf8"), + "managed_inference", +); +const OPENCLAW_BASELINE_DIGEST = digestBaselineEntry(OPENCLAW_BASELINE_ENTRY!); +const OPENCLAW_RESTORED_POLICY = YAML.stringify({ + version: 1, + network_policies: { managed_inference: OPENCLAW_BASELINE_ENTRY }, +}); + +describe("excludeBaselineEntry persistence boundary (#7178)", () => { + beforeEach(() => { + vi.spyOn(openshellResolveModule, "resolveOpenshell").mockReturnValue("/usr/bin/openshell"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.runCapture.mockReturnValue(LIVE_POLICY); + mocks.run.mockReturnValue({ status: 0 }); + mocks.getSandbox.mockReturnValue({ + name: "alpha", + agent: "hermes", + agentVersion: "1.2.3", + }); + mocks.getBaselineExclusions.mockReturnValue([]); + mocks.getBaselineExclusionTransition.mockReturnValue(null); + mocks.beginBaselineExclusionTransition.mockReturnValue(false); + mocks.clearBaselineExclusionTransition.mockReturnValue(true); + mocks.commitBaselineExclusionTransition.mockReturnValue(true); + mocks.addBaselineExclusion.mockReturnValue(true); + mocks.removeBaselineExclusion.mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const mock of Object.values(mocks)) mock.mockReset(); + }); + + it("does not narrow live egress when the exclusion cannot be recorded durably", () => { + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.beginBaselineExclusionTransition).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ + operation: "exclude", + exclusion: expect.objectContaining({ + version: 1, + agent: "hermes", + key: "nous_research", + digest: LIVE_DIGEST, + appliedAgentVersion: "1.2.3", + }), + targetLiveDigest: null, + }), + ); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no live policy changes")); + }); + + it("clears a fresh transaction when live narrowing fails", () => { + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.run.mockReturnValue({ status: 19 }); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; + expect(mocks.clearBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + }); + + it("publishes committed intent only after exact live narrowing is verified", () => { + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.runCapture + .mockReturnValueOnce(LIVE_POLICY) + .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n"); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + true, + ); + + const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; + expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + }); + + it("commits when a failed OpenShell result nevertheless reached the exact live target", () => { + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.run.mockReturnValue({ status: 19 }); + mocks.runCapture + .mockReturnValueOnce(LIVE_POLICY) + .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n"); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + true, + ); + + expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledOnce(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + }); + + it.each([ + ["returns false", () => false], + [ + "throws", + () => { + throw new Error("disk unavailable"); + }, + ], + ])("preserves a verified exclusion journal when finalization %s (#7178)", (_label, finalize) => { + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.commitBaselineExclusionTransition.mockImplementation(finalize); + mocks.runCapture + .mockReturnValueOnce(LIVE_POLICY) + .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n"); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledOnce(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("durable journal could not be finalized"), + ); + }); + + it("preserves the journal when post-write live readback is unavailable (#7178)", () => { + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.runCapture.mockReturnValueOnce(LIVE_POLICY).mockReturnValueOnce(""); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Could not verify the live"), + ); + }); + + it("pins every live read and write to the sandbox's recorded gateway (#7178)", () => { + vi.stubEnv("OPENSHELL_GATEWAY", "ambient-gateway"); + mocks.getSandbox.mockReturnValue({ + name: "alpha", + agent: "hermes", + agentVersion: "1.2.3", + gatewayName: "nemoclaw-18080", + gatewayPort: 18080, + }); + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.runCapture + .mockImplementationOnce((_command, options) => { + expect(process.env.OPENSHELL_GATEWAY).toBe("ambient-gateway"); + expect(options).toMatchObject({ env: { OPENSHELL_GATEWAY: "nemoclaw-18080" } }); + return LIVE_POLICY; + }) + .mockImplementationOnce((_command, options) => { + expect(process.env.OPENSHELL_GATEWAY).toBe("ambient-gateway"); + expect(options).toMatchObject({ env: { OPENSHELL_GATEWAY: "nemoclaw-18080" } }); + return "version: 1\nnetwork_policies: {}\n"; + }); + mocks.run.mockImplementation((_command, options) => { + expect(process.env.OPENSHELL_GATEWAY).toBe("ambient-gateway"); + expect(options).toMatchObject({ env: { OPENSHELL_GATEWAY: "nemoclaw-18080" } }); + return { status: 0 }; + }); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + true, + ); + expect(process.env.OPENSHELL_GATEWAY).toBe("ambient-gateway"); + }); + + it("rejects an ambient OpenShell gateway endpoint before live mutation (#7178)", () => { + vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://other.example.test"); + + expect(() => + excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true }), + ).toThrow(/OPENSHELL_GATEWAY_ENDPOINT is set/); + expect(mocks.runCapture).not.toHaveBeenCalled(); + expect(mocks.run).not.toHaveBeenCalled(); + }); + + it.each([ + ["returns false", () => false], + [ + "throws", + () => { + throw new Error("disk unavailable"); + }, + ], + ])("preserves and reports the journal when exclusion compensation %s", (_label, compensate) => { + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.run.mockReturnValue({ status: 19 }); + mocks.clearBaselineExclusionTransition.mockImplementation(compensate); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("durable journal was preserved"), + ); + }); + + it("finalizes an interrupted exclusion when the exact live target is already present", () => { + mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies: {}\n"); + mocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-exclude", + operation: "exclude", + exclusion: { version: 1, agent: "hermes", key: "nous_research", digest: LIVE_DIGEST }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:00.000Z", + }); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + true, + ); + + expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", "tx-exclude"); + expect(mocks.run).not.toHaveBeenCalled(); + }); + + it("keeps an interrupted exclusion fail-closed when live policy matches neither side", () => { + mocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-exclude", + operation: "exclude", + exclusion: { version: 1, agent: "hermes", key: "nous_research", digest: LIVE_DIGEST }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:00.000Z", + }); + mocks.runCapture.mockReturnValue( + LIVE_POLICY.replace("nousresearch.com", "third-state.example.test"), + ); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("matches neither side")); + }); + + it("does not mistake a malformed same-key live entry for the absent exclude target", () => { + mocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-exclude", + operation: "exclude", + exclusion: { version: 1, agent: "hermes", key: "nous_research", digest: LIVE_DIGEST }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:00.000Z", + }); + mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies:\n nous_research: malformed\n"); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + }); + + it("refuses to remove a live entry that changed after the operator preview", () => { + mocks.runCapture.mockReturnValue( + LIVE_POLICY.replace("nousresearch.com", "changed.example.test"), + ); + + expect(excludeBaselineEntry("alpha", "nous_research", "stale-digest", { nonFatal: true })).toBe( + false, + ); + + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.addBaselineExclusion).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("changed after preview")); + }); +}); + +describe("restoreBaselineEntry persistence boundary (#7178)", () => { + const RECORDED = { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: LIVE_DIGEST, + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }; + + beforeEach(() => { + vi.spyOn(openshellResolveModule, "resolveOpenshell").mockReturnValue("/usr/bin/openshell"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies: {}\n"); + mocks.run.mockReturnValue({ status: 0 }); + mocks.getSandbox.mockReturnValue({ + name: "alpha", + agent: "hermes", + agentVersion: "1.2.3", + }); + mocks.getBaselineExclusions.mockReturnValue([RECORDED]); + mocks.getBaselineExclusionTransition.mockReturnValue(null); + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.clearBaselineExclusionTransition.mockReturnValue(true); + mocks.commitBaselineExclusionTransition.mockReturnValue(true); + mocks.removeBaselineExclusion.mockReturnValue(true); + mocks.addBaselineExclusion.mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const mock of Object.values(mocks)) mock.mockReset(); + }); + + it("does not widen live egress when its durable transaction cannot be recorded", () => { + mocks.beginBaselineExclusionTransition.mockReturnValue(false); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); + + expect(mocks.run).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no live policy changes")); + }); + + it("clears the restore transaction when live policy restoration fails", () => { + mocks.run.mockReturnValue({ status: 19 }); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); + + const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; + expect(mocks.clearBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + }); + + it("publishes the restore only after exact live widening is verified", () => { + expect(HERMES_BASELINE_ENTRY).not.toBeNull(); + mocks.runCapture + .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n") + .mockReturnValueOnce(HERMES_RESTORED_POLICY); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(true); + + const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; + expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + }); + + it("restores the current baseline before clearing an exclusion recorded for another agent (#7194)", () => { + const staleExclusion = { + ...RECORDED, + agent: "hermes", + key: "managed_inference", + digest: HERMES_MANAGED_INFERENCE_DIGEST, + }; + mocks.getSandbox.mockReturnValue({ + name: "alpha", + agent: "openclaw", + agentVersion: "2.0.0", + }); + mocks.getBaselineExclusions.mockReturnValue([staleExclusion]); + mocks.runCapture + .mockReturnValueOnce("version: 1\nnetwork_policies: {}\n") + .mockReturnValueOnce(OPENCLAW_RESTORED_POLICY); + + expect(OPENCLAW_BASELINE_ENTRY).not.toBeNull(); + expect(restoreBaselineEntry("alpha", "managed_inference", { nonFatal: true })).toBe(true); + + expect(mocks.beginBaselineExclusionTransition).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ + operation: "restore", + exclusion: staleExclusion, + targetLiveDigest: OPENCLAW_BASELINE_DIGEST, + }), + ); + expect(mocks.run).toHaveBeenCalledOnce(); + const transaction = mocks.beginBaselineExclusionTransition.mock.calls[0]?.[1]; + expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", transaction.id); + expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled(); + }); + + it.each([ + ["returns false", () => false], + [ + "throws", + () => { + throw new Error("disk unavailable"); + }, + ], + ])("preserves and reports the journal when restore compensation %s", (_label, compensate) => { + mocks.run.mockReturnValue({ status: 19 }); + mocks.clearBaselineExclusionTransition.mockImplementation(compensate); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("durable journal was preserved"), + ); + }); + + it("finalizes an interrupted restore when the exact live target is already present", () => { + mocks.runCapture.mockReturnValue(HERMES_RESTORED_POLICY); + mocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-restore", + operation: "restore", + exclusion: RECORDED, + targetLiveDigest: HERMES_BASELINE_DIGEST, + startedAt: "2026-07-19T00:00:00.000Z", + }); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(true); + + expect(mocks.commitBaselineExclusionTransition).toHaveBeenCalledWith("alpha", "tx-restore"); + expect(mocks.run).not.toHaveBeenCalled(); + }); + + it("keeps an interrupted restore pending when the release baseline changed (#7178)", () => { + mocks.runCapture.mockReturnValue(LIVE_POLICY); + mocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-restore", + operation: "restore", + exclusion: RECORDED, + targetLiveDigest: LIVE_DIGEST, + startedAt: "2026-07-19T00:00:00.000Z", + }); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); + + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("current release baseline for 'nous_research' changed"), + ); + }); + + it("keeps an interrupted restore pending when the release baseline removed its key (#7178)", () => { + const legacyTargetPolicy = YAML.stringify({ + version: 1, + network_policies: { legacy_entry: LIVE_ENTRY }, + }); + const legacyExclusion = { + version: 1 as const, + agent: "hermes", + key: "legacy_entry", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }; + mocks.runCapture.mockReturnValue(legacyTargetPolicy); + mocks.getBaselineExclusions.mockReturnValue([legacyExclusion]); + mocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-restore", + operation: "restore", + exclusion: legacyExclusion, + targetLiveDigest: LIVE_DIGEST, + startedAt: "2026-07-19T00:00:00.000Z", + }); + + expect(restoreBaselineEntry("alpha", "legacy_entry", { nonFatal: true })).toBe(false); + + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("current release baseline for 'legacy_entry' changed"), + ); + }); + + it("keeps an interrupted restore pending when its agent baseline is unreadable (#7178)", () => { + mocks.getSandbox.mockReturnValue({ + name: "alpha", + agent: "agent-without-a-readable-baseline", + agentVersion: "1.2.3", + }); + mocks.runCapture.mockReturnValue(LIVE_POLICY); + mocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-restore", + operation: "restore", + exclusion: RECORDED, + targetLiveDigest: LIVE_DIGEST, + startedAt: "2026-07-19T00:00:00.000Z", + }); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); + + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("current release baseline for 'nous_research' is unreadable"), + ); + }); + + it("keeps an interrupted restore pending when durable exclusion intent changed (#7178)", () => { + mocks.runCapture.mockReturnValue(HERMES_RESTORED_POLICY); + mocks.getBaselineExclusions.mockReturnValue([{ ...RECORDED, digest: "changed" }]); + mocks.getBaselineExclusionTransition.mockReturnValue({ + id: "tx-restore", + operation: "restore", + exclusion: RECORDED, + targetLiveDigest: HERMES_BASELINE_DIGEST, + startedAt: "2026-07-19T00:00:00.000Z", + }); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); + + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("durable exclusion for 'nous_research' changed"), + ); + }); +}); + +describe("baseline exclusion live verification boundary (#7194)", () => { + const exclusion = { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: HERMES_BASELINE_DIGEST, + }; + + beforeEach(() => { + mocks.getSandbox.mockReturnValue({ name: "alpha", agent: "hermes" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const mock of Object.values(mocks)) mock.mockReset(); + }); + + it("reports a mismatch when the excluded key remains in the observed live policy", () => { + mocks.runCapture.mockReturnValue(HERMES_RESTORED_POLICY); + + expect(getBaselineExclusionRuntimeStatus("alpha", exclusion)).toBe("live-policy-mismatch"); + }); + + it("reports excluded only when the observed live policy omits the reviewed key", () => { + mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies: {}\n"); + + expect(getBaselineExclusionRuntimeStatus("alpha", exclusion)).toBe("excluded"); + }); + + it("refuses to reintroduce an excluded Hermes pypi key through a live preset (#7194)", () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const pypiPreset = loadPresetForSandbox("alpha", "pypi"); + expect(pypiPreset).not.toBeNull(); + mocks.getBaselineExclusions.mockReturnValue([{ ...exclusion, key: "pypi" }]); + + expect(applyPresetContent("alpha", "pypi", pypiPreset!, { nonFatal: true })).toBe(false); + expect(mocks.runCapture).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("reserved by a baseline exclusion"), + ); + }); +}); diff --git a/src/lib/policy/baseline-exclusion-support-impact.test.ts b/src/lib/policy/baseline-exclusion-support-impact.test.ts new file mode 100644 index 00000000000..3c785dadf41 --- /dev/null +++ b/src/lib/policy/baseline-exclusion-support-impact.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { + getBaselineExclusionFeatureImpact, + isProtectedBaselineExclusionKey, + listBaselineEntryKeys, +} from "./baseline-exclusion"; + +const BASELINES = [ + { + agent: "openclaw", + path: "nemoclaw-blueprint/policies/openclaw-sandbox.yaml", + }, + { + agent: "hermes", + path: "agents/hermes/policy-additions.yaml", + }, + { + agent: "langchain-deepagents-code", + path: "agents/langchain-deepagents-code/policy-additions.yaml", + }, +] as const; + +describe("baseline exclusion supported-feature disclosure (#7178)", () => { + it("names the affected Hermes feature for nous_research", () => { + expect(getBaselineExclusionFeatureImpact("hermes", "nous_research")).toBe( + "Hermes public metadata lookup and agent updates may stop working.", + ); + }); + + it("names a different affected feature for another baseline entry", () => { + expect(getBaselineExclusionFeatureImpact("openclaw", "npm_registry")).toBe( + "OpenClaw plugin installation from npm may stop working.", + ); + }); + + it.each(BASELINES)("defines an impact for every excludable $agent baseline entry", ({ + agent, + path, + }) => { + const content = fs.readFileSync(path, "utf8"); + const excludableKeys = listBaselineEntryKeys(content).filter( + (key) => !isProtectedBaselineExclusionKey(key), + ); + + expect(excludableKeys).not.toHaveLength(0); + for (const key of excludableKeys) { + expect(getBaselineExclusionFeatureImpact(agent, key), `${agent}:${key}`).not.toBeNull(); + } + }); + + it("returns no disclosure for an unreviewed baseline entry", () => { + expect(getBaselineExclusionFeatureImpact("hermes", "future_entry")).toBeNull(); + }); +}); diff --git a/src/lib/policy/baseline-exclusion.test.ts b/src/lib/policy/baseline-exclusion.test.ts new file mode 100644 index 00000000000..1e0157ce9d0 --- /dev/null +++ b/src/lib/policy/baseline-exclusion.test.ts @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { + applyBaselineExclusions, + BaselineExclusionDriftError, + BaselineExclusionSourceError, + digestBaselineEntry, + evaluateBaselineExclusionRuntimeStatus, + getBaselineEntry, + listBaselineEntryKeys, + mergeBaselineEntryIntoPolicy, + ProtectedBaselineExclusionError, + removeBaselineEntryFromPolicy, + renderBaselineEntryScope, + resolveBaselineExclusion, +} from "./baseline-exclusion"; + +const BASE_POLICY = `version: 1 +network_policies: + nous_research: + name: nous_research + endpoints: + - host: nousresearch.com + port: 443 + protocol: rest + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /usr/local/bin/hermes } + managed_inference: + name: managed_inference + endpoints: + - host: inference.local + port: 443 + protocol: rest + rules: + - allow: { method: POST, path: "/v1/**" } +`; + +function digestOf(key: string, policy = BASE_POLICY): string { + const entry = getBaselineEntry(policy, key); + expect(entry).not.toBeNull(); + return digestBaselineEntry(entry!); +} + +function exclusion(key: string, digest: string) { + return { version: 1 as const, agent: "hermes", key, digest }; +} + +describe("baseline-exclusion digest (#7178)", () => { + it("is stable across key ordering and whitespace", () => { + const entry = getBaselineEntry(BASE_POLICY, "nous_research"); + expect(entry).not.toBeNull(); + const reordered = YAML.parse( + YAML.stringify({ + binaries: entry!.binaries, + endpoints: entry!.endpoints, + name: entry!.name, + }), + ); + expect(digestBaselineEntry(reordered)).toBe(digestBaselineEntry(entry!)); + }); + + it("changes when the entry content changes", () => { + const entry = getBaselineEntry(BASE_POLICY, "nous_research"); + expect(entry).not.toBeNull(); + const widened = YAML.parse(YAML.stringify(entry!)); + (widened.endpoints as { host: string }[]).push({ host: "evil.example" }); + expect(digestBaselineEntry(widened)).not.toBe(digestBaselineEntry(entry!)); + }); +}); + +describe("baseline-exclusion enumeration (#7178)", () => { + it("lists every baseline key", () => { + expect(listBaselineEntryKeys(BASE_POLICY)).toEqual(["nous_research", "managed_inference"]); + }); + + it("returns null for an absent key", () => { + expect(getBaselineEntry(BASE_POLICY, "absent")).toBeNull(); + }); + + it("does not treat inherited object properties as baseline keys", () => { + expect(getBaselineEntry(BASE_POLICY, "__proto__")).toBeNull(); + }); +}); + +describe("baseline-exclusion drift resolution (#7178)", () => { + it("reports no drift when the digest matches", () => { + const resolution = resolveBaselineExclusion(BASE_POLICY, { + version: 1, + agent: "hermes", + key: "nous_research", + digest: digestOf("nous_research"), + }); + expect(resolution.drift).toBeNull(); + expect(resolution.entry).not.toBeNull(); + }); + + it("reports 'changed' when the entry content no longer matches", () => { + const resolution = resolveBaselineExclusion(BASE_POLICY, { + version: 1, + agent: "hermes", + key: "nous_research", + digest: "stale-digest", + }); + expect(resolution.drift).toBe("changed"); + }); + + it("reports 'missing' when the release dropped the entry", () => { + const resolution = resolveBaselineExclusion(BASE_POLICY, { + version: 1, + agent: "hermes", + key: "absent", + digest: "any", + }); + expect(resolution.drift).toBe("missing"); + }); +}); + +describe("baseline-exclusion scope render (#7178)", () => { + it("previews host, method/path rules, and binaries", () => { + const entry = getBaselineEntry(BASE_POLICY, "nous_research"); + expect(entry).not.toBeNull(); + const lines = renderBaselineEntryScope("nous_research", entry!).join("\n"); + expect(lines).toContain("nous_research"); + expect(lines).toContain("nousresearch.com:443"); + expect(lines).toContain("GET /**"); + expect(lines).toContain("POST /**"); + expect(lines).toContain("/usr/local/bin/hermes"); + }); +}); + +describe("baseline-exclusion policy edits (#7178)", () => { + it("removes a baseline entry by exact key", () => { + const { policy, removed } = removeBaselineEntryFromPolicy(BASE_POLICY, "nous_research"); + expect(removed).toBe(true); + const keys = Object.keys(YAML.parse(policy).network_policies); + expect(keys).toEqual(["managed_inference"]); + }); + + it("is a no-op for an absent key", () => { + const { policy, removed } = removeBaselineEntryFromPolicy(BASE_POLICY, "absent"); + expect(removed).toBe(false); + expect(policy).toBe(BASE_POLICY); + }); + + it("merges a baseline entry back under its key", () => { + const entry = getBaselineEntry(BASE_POLICY, "nous_research"); + expect(entry).not.toBeNull(); + const { policy: removedPolicy } = removeBaselineEntryFromPolicy(BASE_POLICY, "nous_research"); + const restored = mergeBaselineEntryIntoPolicy(removedPolicy, "nous_research", entry!); + expect(Object.keys(YAML.parse(restored).network_policies).sort()).toEqual([ + "managed_inference", + "nous_research", + ]); + }); +}); + +describe("applyBaselineExclusions fail-closed (#7178)", () => { + it("drops matching entries and reports the excluded keys", () => { + const { content, excludedKeys } = applyBaselineExclusions( + BASE_POLICY, + [exclusion("nous_research", digestOf("nous_research"))], + "hermes", + ); + expect(excludedKeys).toEqual(["nous_research"]); + expect(Object.keys(YAML.parse(content).network_policies)).toEqual(["managed_inference"]); + }); + + it("throws on changed content instead of replaying a stale approval", () => { + expect(() => + applyBaselineExclusions(BASE_POLICY, [exclusion("nous_research", "stale")], "hermes"), + ).toThrowError(BaselineExclusionDriftError); + }); + + it("throws when the release removed the entry", () => { + let error: unknown; + try { + applyBaselineExclusions(BASE_POLICY, [exclusion("absent", "any")], "hermes"); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(BaselineExclusionDriftError); + expect((error as BaselineExclusionDriftError).reason).toBe("missing"); + expect((error as Error).message).toContain("Clear it with 'policy restore'."); + expect((error as Error).message).not.toContain("re-exclude"); + }); + + it("rejects protected entries even when imported state has a matching digest", () => { + expect(() => + applyBaselineExclusions( + BASE_POLICY, + [exclusion("managed_inference", digestOf("managed_inference"))], + "hermes", + ), + ).toThrowError(ProtectedBaselineExclusionError); + }); + + it("rejects an approval recorded for a different agent baseline (#7194)", () => { + expect(() => + applyBaselineExclusions( + BASE_POLICY, + [exclusion("nous_research", digestOf("nous_research"))], + "openclaw", + ), + ).toThrowError(BaselineExclusionSourceError); + }); +}); + +describe("baseline exclusion runtime verification (#7194)", () => { + const recorded = exclusion("nous_research", "digest"); + + it("reports excluded only when the matching baseline key is absent live", () => { + expect(evaluateBaselineExclusionRuntimeStatus(recorded, "hermes", "digest", null)).toBe( + "excluded", + ); + }); + + it("reports a live mismatch when any value remains under the excluded key", () => { + expect(evaluateBaselineExclusionRuntimeStatus(recorded, "hermes", "digest", "other")).toBe( + "live-policy-mismatch", + ); + }); + + it("checks the approved agent before baseline and live digests", () => { + expect(evaluateBaselineExclusionRuntimeStatus(recorded, "openclaw", undefined, undefined)).toBe( + "agent-changed", + ); + }); +}); diff --git a/src/lib/policy/baseline-exclusion.ts b/src/lib/policy/baseline-exclusion.ts new file mode 100644 index 00000000000..32489ac5ef3 --- /dev/null +++ b/src/lib/policy/baseline-exclusion.ts @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import YAML from "yaml"; + +import { type JsonValue } from "../core/json-types"; +import { + isPolicyDocument, + isPolicyObject, + type PolicyObject, + type PolicyValue, + parseNetworkPolicies, +} from "./preset-parsing"; + +/** Support posture disclosed whenever a sandbox has a baseline exclusion. */ +export const BASELINE_EXCLUSION_SUPPORT_IMPACT = + "Excluded egress leaves dependent agent features unsupported for this sandbox."; + +const PROTECTED_BASELINE_EXCLUSION_KEYS = new Set(["managed_inference"]); + +const BASELINE_EXCLUSION_FEATURE_IMPACTS: Readonly< + Record>> +> = { + openclaw: { + nvidia: "Direct NVIDIA API inference may stop working.", + openclaw_gateway_dialback: + "OpenClaw sessions_spawn and multi-agent delegation may stop working.", + clawhub: "ClawHub authentication and skill or plugin discovery may stop working.", + openclaw_api: "OpenClaw authentication and plugin discovery may stop working.", + openclaw_docs: "In-sandbox access to OpenClaw documentation may stop working.", + npm_registry: "OpenClaw plugin installation from npm may stop working.", + }, + hermes: { + nvidia: "Direct NVIDIA API inference may stop working.", + nous_research: "Hermes public metadata lookup and agent updates may stop working.", + pypi: "Hermes skill or plugin dependency installation through pip may stop working.", + }, + "langchain-deepagents-code": { + github: "Git operations and GitHub API or source access may stop working.", + pypi: "Python package installation through pip may stop working.", + }, +}; + +/** Baseline entries that remain mandatory for the managed sandbox contract. */ +export function isProtectedBaselineExclusionKey(key: string): boolean { + return PROTECTED_BASELINE_EXCLUSION_KEYS.has(key); +} + +/** + * Entry-specific supported features that an exclusion can disable. Missing + * metadata must block exclusion so a newly added baseline entry cannot bypass + * the operator disclosure requirement. + */ +export function getBaselineExclusionFeatureImpact(agent: string, key: string): string | null { + return BASELINE_EXCLUSION_FEATURE_IMPACTS[agent]?.[key] ?? null; +} + +export interface BaselineExclusionRequest { + readonly version: 1; + readonly agent: string; + readonly key: string; + readonly digest: string; +} + +export type BaselineExclusionRuntimeStatus = + | "excluded" + | "agent-changed" + | "baseline-unreadable" + | "content-changed" + | "no-longer-in-baseline" + | "live-policy-unreadable" + | "live-policy-mismatch"; + +/** Compare reviewed intent with both the release baseline and observed live policy. */ +export function evaluateBaselineExclusionRuntimeStatus( + exclusion: BaselineExclusionRequest, + currentAgent: string, + currentBaselineDigest: string | null | undefined, + liveDigest: string | null | undefined, +): BaselineExclusionRuntimeStatus { + if (exclusion.agent !== currentAgent) return "agent-changed"; + if (currentBaselineDigest === undefined) return "baseline-unreadable"; + if (currentBaselineDigest === null) return "no-longer-in-baseline"; + if (currentBaselineDigest !== exclusion.digest) return "content-changed"; + if (liveDigest === undefined) return "live-policy-unreadable"; + return liveDigest === null ? "excluded" : "live-policy-mismatch"; +} + +export type BaselineDriftReason = "missing" | "changed"; + +export interface BaselineExclusionResolution { + readonly entry: PolicyObject | null; + readonly currentDigest: string | null; + readonly drift: BaselineDriftReason | null; +} + +function canonicalize(value: PolicyValue): JsonValue { + if (Array.isArray(value)) return value.map(canonicalize); + if (isPolicyObject(value)) { + const sorted: PolicyObject = {}; + for (const key of Object.keys(value).sort()) { + const canonical = canonicalize(value[key]); + if (canonical !== undefined) sorted[key] = canonical; + } + return sorted; + } + return value; +} + +/** + * Content digest over a single baseline network policy entry, stable across + * YAML key ordering and whitespace. Binds an operator's exclusion approval to + * the exact reviewed egress so a later release that redefines the entry + * invalidates the approval instead of silently replaying it. + */ +export function digestBaselineEntry(entry: PolicyValue): string { + return createHash("sha256") + .update(JSON.stringify(canonicalize(entry))) + .digest("hex"); +} + +/** Exact baseline entry for a key, or null when the base policy omits it. */ +export function getBaselineEntry(basePolicyContent: string, key: string): PolicyObject | null { + const networkPolicies = parseNetworkPolicies(basePolicyContent); + if (!networkPolicies) return null; + if (!Object.prototype.hasOwnProperty.call(networkPolicies, key)) return null; + const entry = networkPolicies[key]; + return isPolicyObject(entry) ? entry : null; +} + +/** Keys of every baseline network policy entry, in declaration order. */ +export function listBaselineEntryKeys(basePolicyContent: string): string[] { + const networkPolicies = parseNetworkPolicies(basePolicyContent); + return networkPolicies ? Object.keys(networkPolicies) : []; +} + +/** + * Resolve an exclusion request against the current base policy: report the + * entry, its current digest, and any drift (`missing` when the release dropped + * the key, `changed` when its content no longer matches the approved digest). + */ +export function resolveBaselineExclusion( + basePolicyContent: string, + request: BaselineExclusionRequest, +): BaselineExclusionResolution { + const entry = getBaselineEntry(basePolicyContent, request.key); + if (!entry) return { entry: null, currentDigest: null, drift: "missing" }; + const currentDigest = digestBaselineEntry(entry); + return { + entry, + currentDigest, + drift: currentDigest === request.digest ? null : "changed", + }; +} + +/** + * Raised when a recorded exclusion no longer matches the current baseline, so + * the create/rebuild policy generation fails closed instead of replaying a + * stale approval against changed egress. + */ +export class BaselineExclusionDriftError extends Error { + readonly key: string; + readonly reason: BaselineDriftReason; + + constructor(key: string, reason: BaselineDriftReason) { + super( + reason === "missing" + ? `Baseline entry '${key}' no longer exists in the current agent baseline; its exclusion approval is stale. Clear it with 'policy restore'.` + : `Baseline entry '${key}' changed since it was excluded; the exclusion approval is invalid. Re-review and re-exclude it, or restore it with 'policy restore'.`, + ); + this.name = "BaselineExclusionDriftError"; + this.key = key; + this.reason = reason; + } +} + +/** + * Raised when durable state attempts to exclude an entry that the supported + * sandbox contract requires. This check belongs in the replay path as well as + * the CLI so imported or manually edited registry state cannot bypass it. + */ +export class ProtectedBaselineExclusionError extends Error { + readonly key: string; + + constructor(key: string) { + super(`Baseline entry '${key}' is required and cannot be excluded.`); + this.name = "ProtectedBaselineExclusionError"; + this.key = key; + } +} + +/** Raised when durable approval belongs to a different agent baseline contract. */ +export class BaselineExclusionSourceError extends Error { + readonly key: string; + readonly approvedAgent: string; + readonly currentAgent: string; + + constructor(key: string, approvedAgent: string, currentAgent: string) { + super( + `Baseline exclusion '${key}' was approved for agent '${approvedAgent}', not '${currentAgent}'. Restore or re-approve it for the current agent before rebuilding.`, + ); + this.name = "BaselineExclusionSourceError"; + this.key = key; + this.approvedAgent = approvedAgent; + this.currentAgent = currentAgent; + } +} + +/** + * Apply recorded exclusions to a base policy for create/rebuild. Verifies each + * approval's digest against the current baseline and drops the matching entry; + * throws `BaselineExclusionDriftError` on any missing or changed entry so a + * release that redefined the egress forces re-review. + */ +export function applyBaselineExclusions( + basePolicyContent: string, + requests: readonly BaselineExclusionRequest[], + currentAgent: string, +): { content: string; excludedKeys: string[] } { + let content = basePolicyContent; + const excludedKeys: string[] = []; + for (const request of requests) { + if (request.agent !== currentAgent) { + throw new BaselineExclusionSourceError(request.key, request.agent, currentAgent); + } + if (isProtectedBaselineExclusionKey(request.key)) { + throw new ProtectedBaselineExclusionError(request.key); + } + const resolution = resolveBaselineExclusion(content, request); + if (resolution.drift) throw new BaselineExclusionDriftError(request.key, resolution.drift); + const removal = removeBaselineEntryFromPolicy(content, request.key); + if (!removal.removed) throw new BaselineExclusionDriftError(request.key, "missing"); + content = removal.policy; + excludedKeys.push(request.key); + } + return { content, excludedKeys }; +} + +function scalarText(value: PolicyValue): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return ""; + return String(value); +} + +/** + * Human-readable preview of every endpoint, method/path rule, and binary an + * exclusion removes, so the operator reviews the exact scope before approving. + */ +export function renderBaselineEntryScope(key: string, entry: PolicyObject): string[] { + const lines: string[] = [` ${key}:`]; + const endpoints = entry.endpoints; + if (Array.isArray(endpoints)) { + for (const endpoint of endpoints) { + if (!isPolicyObject(endpoint)) continue; + const host = scalarText(endpoint.host); + const port = scalarText(endpoint.port); + const protocol = scalarText(endpoint.protocol); + const location = [host, port ? `:${port}` : "", protocol ? ` (${protocol})` : ""].join(""); + lines.push(` endpoint: ${location || "(unspecified)"}`); + const rules = endpoint.rules; + if (Array.isArray(rules)) { + for (const rule of rules) { + if (!isPolicyObject(rule)) continue; + const allow = isPolicyObject(rule.allow) ? rule.allow : null; + const deny = isPolicyObject(rule.deny) ? rule.deny : null; + const verb = allow ? "allow" : deny ? "deny" : "rule"; + const spec = allow ?? deny; + const method = spec ? scalarText(spec.method) : ""; + const routePath = spec ? scalarText(spec.path) : ""; + lines.push(` ${verb}: ${[method, routePath].filter(Boolean).join(" ") || "(any)"}`); + } + } + } + } + const binaries = entry.binaries; + if (Array.isArray(binaries)) { + for (const binary of binaries) { + const binaryPath = isPolicyObject(binary) ? scalarText(binary.path) : scalarText(binary); + if (binaryPath) lines.push(` binary: ${binaryPath}`); + } + } + return lines; +} + +function parsePolicyDocumentOrNull(policyContent: string): PolicyObject | null { + try { + const parsed = YAML.parse(policyContent); + return isPolicyDocument(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Remove a single baseline entry from a policy document by exact key. Returns + * the unchanged policy and `removed: false` when the key is absent or the + * document has no object-shaped `network_policies`. + */ +export function removeBaselineEntryFromPolicy( + currentPolicy: string, + key: string, +): { policy: string; removed: boolean } { + const document = parsePolicyDocumentOrNull(currentPolicy); + const networkPolicies = document?.network_policies; + if ( + !document || + !networkPolicies || + typeof networkPolicies !== "object" || + Array.isArray(networkPolicies) || + !Object.prototype.hasOwnProperty.call(networkPolicies, key) + ) { + return { policy: currentPolicy, removed: false }; + } + delete networkPolicies[key]; + document.network_policies = networkPolicies; + return { policy: YAML.stringify(document), removed: true }; +} + +/** + * Merge a baseline entry back into a policy document under its key, restoring a + * previously excluded entry against the current release baseline. + */ +export function mergeBaselineEntryIntoPolicy( + currentPolicy: string, + key: string, + entry: PolicyObject, +): string { + const document = parsePolicyDocumentOrNull(currentPolicy) ?? { version: 1 }; + const existing = document.network_policies; + const networkPolicies = + existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {}; + networkPolicies[key] = entry; + document.version = Number(document.version) || 1; + document.network_policies = networkPolicies; + return YAML.stringify(document); +} diff --git a/src/lib/policy/baseline-policy-resolution.test.ts b/src/lib/policy/baseline-policy-resolution.test.ts new file mode 100644 index 00000000000..a810e596a62 --- /dev/null +++ b/src/lib/policy/baseline-policy-resolution.test.ts @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as agentDefs from "../agent/defs"; +import { ROOT } from "../runner"; +import * as registry from "../state/registry"; +import { resolveSandboxBaselinePolicy } from "./index"; + +const tempDirs: string[] = []; + +function writePolicy(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-baseline-policy-")); + tempDirs.push(dir); + const policyPath = path.join(dir, "policy-additions.yaml"); + fs.writeFileSync(policyPath, content); + return policyPath; +} + +function useAgentPolicy(content: string): void { + vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: "hermes" } as never); + vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ + name: "hermes", + policyAdditionsPath: writePolicy(content), + } as never); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("sandbox baseline policy resolution (#7194)", () => { + it.each([null, "openclaw"])("uses the OpenClaw baseline for agent %s (#7194)", (agent) => { + vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent } as never); + const loadAgentSpy = vi.spyOn(agentDefs, "loadAgent"); + + expect(resolveSandboxBaselinePolicy("alpha")?.policyPath).toBe( + path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), + ); + expect(loadAgentSpy).not.toHaveBeenCalled(); + }); + + it.each([ + { label: "missing", policyAdditionsPath: null }, + { label: "unreadable", policyAdditionsPath: ROOT }, + ])("refuses to substitute OpenClaw for a $label recorded-agent baseline (#7194)", ({ + policyAdditionsPath, + }) => { + vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha", agent: "hermes" } as never); + vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ + name: "hermes", + policyAdditionsPath, + } as never); + + expect(() => resolveSandboxBaselinePolicy("alpha")).toThrow( + "Refusing to substitute the OpenClaw baseline", + ); + }); + + it("rejects malformed agent baseline YAML through the canonical parser (#7194)", () => { + useAgentPolicy("version: [unterminated"); + + expect(() => resolveSandboxBaselinePolicy("alpha")).toThrow( + "Sandbox policy is malformed or is not an OpenShell policy YAML mapping", + ); + }); + + it("rejects a schema-invalid agent baseline with an unscoped network entry (#7194)", () => { + useAgentPolicy(` +version: 1 +network_policies: + unsafe_entry: + name: unsafe_entry + endpoints: + - host: api.example.test + port: 443 + access: full +`); + + expect(() => resolveSandboxBaselinePolicy("alpha")).toThrow( + /does not satisfy the shipped sandbox policy schema \(required: must have required property 'binaries'/, + ); + }); + + it("accepts every checked-in non-OpenClaw agent baseline under the runtime schema (#7194)", () => { + const getSandbox = vi.spyOn(registry, "getSandbox"); + // Keep this immutable: listAgents() observes the shared agents directory, + // where parallel definition tests intentionally create transient manifests. + const agentNames = ["hermes", "langchain-deepagents-code"] as const; + + for (const agentName of agentNames) { + getSandbox.mockReturnValue({ name: "alpha", agent: agentName } as never); + expect(resolveSandboxBaselinePolicy("alpha")?.policyPath).toBe( + agentDefs.loadAgent(agentName).policyAdditionsPath, + ); + } + }); +}); diff --git a/src/lib/policy/context.test.ts b/src/lib/policy/context.test.ts index dfab4b8fde9..0518b4f4812 100644 --- a/src/lib/policy/context.test.ts +++ b/src/lib/policy/context.test.ts @@ -6,9 +6,11 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../state/registry", () => ({ getSandbox: vi.fn(), getCustomPolicies: vi.fn(() => []), + getBaselineExclusions: vi.fn(() => []), })); vi.mock(".", () => ({ + getBaselineExclusionRuntimeStatus: vi.fn(() => "excluded"), getPresetEndpoints: vi.fn(), getGatewayPresets: vi.fn(() => null), listCustomPresets: vi.fn(), @@ -23,8 +25,8 @@ vi.mock("./tiers", () => ({ import * as registry from "../state/registry"; import * as policies from "."; -import { getTier } from "./tiers"; import { buildPolicyContext, renderPolicyContextMarkdown } from "./context"; +import { getTier } from "./tiers"; const SANDBOX = "alpha"; @@ -101,6 +103,10 @@ function resetMocks() { vi.mocked(policies.getPresetEndpoints).mockReset(); vi.mocked(policies.getGatewayPresets).mockReset(); vi.mocked(policies.getGatewayPresets).mockReturnValue(null); + vi.mocked(registry.getBaselineExclusions).mockReset(); + vi.mocked(registry.getBaselineExclusions).mockReturnValue([]); + vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReset(); + vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("excluded"); vi.mocked(getTier).mockReset(); } @@ -125,9 +131,13 @@ describe("buildPolicyContext", () => { expect(ctx.activePresets[0].redactedHostCount).toBe(0); expect(ctx.activePresets[0].verification).toBe("gateway-unavailable"); expect(ctx.knownUnappliedPresets.map((p) => p.name)).toEqual(["github"]); - expect(ctx.approvalPath.inspect).toBe(`nemoclaw ${SANDBOX} policy-list`); - expect(ctx.approvalPath.add).toBe(`nemoclaw ${SANDBOX} policy-add `); - expect(ctx.approvalPath.remove).toBe(`nemoclaw ${SANDBOX} policy-remove `); + expect(ctx.approvalPath.inspect).toBe(`nemoclaw ${SANDBOX} policy list`); + expect(ctx.approvalPath.add).toBe(`nemoclaw ${SANDBOX} policy add `); + expect(ctx.approvalPath.remove).toBe(`nemoclaw ${SANDBOX} policy remove `); + expect(ctx.approvalPath.excludeBaseline).toBe( + `nemoclaw ${SANDBOX} policy exclude --dry-run`, + ); + expect(ctx.approvalPath.restoreBaseline).toBe(`nemoclaw ${SANDBOX} policy restore `); expect(ctx.supportBoundaries.some((b) => b.capability === "host allowlist enforcement")).toBe( true, ); @@ -242,6 +252,170 @@ describe("buildPolicyContext", () => { const internal = ctx.activePresets.find((p) => p.name === "internal"); expect(internal?.allowedHostCategories).toEqual(["internal.example.com"]); }); + + it("reports baseline exclusions with a status per current digest agreement (#7194)", () => { + resetMocks(); + mockBuiltinPresets(); + vi.mocked(getTier).mockReturnValue(null); + stubRegistry({ policies: [], policyTier: undefined }); + vi.mocked(registry.getBaselineExclusions).mockReturnValue([ + { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "digest-1", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + { + version: 1, + agent: "openclaw", + key: "changed_entry", + digest: "digest-stale", + acknowledgedAt: "2026-07-18T00:00:00.000Z", + }, + { + version: 1, + agent: "openclaw", + key: "dropped_entry", + digest: "digest-2", + acknowledgedAt: "2026-07-17T00:00:00.000Z", + }, + ]); + const statuses: Record = { + nous_research: "excluded", + changed_entry: "content-changed", + dropped_entry: "no-longer-in-baseline", + }; + vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockImplementation( + (_sandbox, entry) => statuses[entry.key], + ); + + const ctx = buildPolicyContext(SANDBOX); + + expect(ctx.baselineExclusions).toEqual([ + { + key: "changed_entry", + digest: "digest-stale", + acknowledgedAt: "2026-07-18T00:00:00.000Z", + status: "content-changed", + supportImpact: + "Excluded egress leaves dependent agent features unsupported for this sandbox.", + }, + { + key: "dropped_entry", + digest: "digest-2", + acknowledgedAt: "2026-07-17T00:00:00.000Z", + status: "no-longer-in-baseline", + supportImpact: + "Excluded egress leaves dependent agent features unsupported for this sandbox.", + }, + { + key: "nous_research", + digest: "digest-1", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + status: "excluded", + supportImpact: + "Excluded egress leaves dependent agent features unsupported for this sandbox.", + }, + ]); + }); + + it("surfaces an interrupted live-policy transaction as repair-required (#7178)", () => { + resetMocks(); + mockBuiltinPresets(); + vi.mocked(getTier).mockReturnValue(null); + vi.mocked(registry.getSandbox).mockReturnValue({ + name: SANDBOX, + policies: [], + baselineExclusionTransition: { + id: "tx-1", + operation: "exclude", + exclusion: { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "digest-1", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:00.000Z", + }, + }); + + const ctx = buildPolicyContext(SANDBOX); + + expect(ctx.baselineExclusions).toEqual([ + expect.objectContaining({ + key: "nous_research", + status: "pending-exclude-repair", + }), + ]); + const markdown = renderPolicyContextMarkdown(ctx); + expect(markdown).toContain("repair-required"); + expect(markdown).toContain("exclude transaction was interrupted"); + expect(markdown).toContain("rebuild blocked"); + }); + + it.each([ + "exclude", + "restore", + ] as const)("surfaces pending %s repair even when the release baseline is unreadable (#7194)", (operation) => { + resetMocks(); + mockBuiltinPresets(); + vi.mocked(getTier).mockReturnValue(null); + vi.mocked(registry.getBaselineExclusions).mockReturnValue([ + { + version: 1, + agent: "openclaw", + key: "another_entry", + digest: "c".repeat(64), + acknowledgedAt: "2026-07-18T00:00:00.000Z", + }, + { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + ]); + vi.mocked(registry.getSandbox).mockReturnValue({ + name: SANDBOX, + policies: [], + baselineExclusionTransition: { + id: "00000000-0000-4000-8000-000000000001", + operation, + exclusion: { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + targetLiveDigest: operation === "restore" ? "b".repeat(64) : null, + startedAt: "2026-07-19T00:00:00.000Z", + }, + }); + vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("baseline-unreadable"); + + const ctx = buildPolicyContext(SANDBOX); + + expect(ctx.baselineExclusions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "another_entry", status: "baseline-unreadable" }), + expect.objectContaining({ + key: "nous_research", + status: operation === "exclude" ? "pending-exclude-repair" : "pending-restore-repair", + }), + ]), + ); + expect(ctx.baselineExclusions).toHaveLength(2); + expect(policies.getBaselineExclusionRuntimeStatus).toHaveBeenCalledOnce(); + expect(policies.getBaselineExclusionRuntimeStatus).toHaveBeenCalledWith( + SANDBOX, + expect.objectContaining({ key: "another_entry" }), + ); + }); }); describe("renderPolicyContextMarkdown", () => { @@ -274,4 +448,41 @@ describe("renderPolicyContextMarkdown", () => { ); expect(md).toContain("status: verified"); }); + + it("discloses excluded baseline entries and their support impact (#7194)", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + vi.mocked(registry.getBaselineExclusions).mockReturnValue([ + { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "digest-1", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + ]); + vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("excluded"); + + const md = renderPolicyContextMarkdown(buildPolicyContext(SANDBOX)); + + expect(md).toContain("## Baseline exclusions"); + expect(md).toContain("`nous_research`"); + expect(md).toContain("status: excluded"); + expect(md).toContain("Excluded egress leaves dependent agent features unsupported"); + expect(md).toContain("policy restore nous_research"); + }); + + it("reports no baseline exclusions when none are recorded", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const md = renderPolicyContextMarkdown(buildPolicyContext(SANDBOX)); + + expect(md).toContain("## Baseline exclusions"); + expect(md).toMatch(/## Baseline exclusions\n- none/); + }); }); diff --git a/src/lib/policy/context.ts b/src/lib/policy/context.ts index 6ccc0776954..879c672d2f3 100644 --- a/src/lib/policy/context.ts +++ b/src/lib/policy/context.ts @@ -3,12 +3,17 @@ import * as registry from "../state/registry"; import { + getBaselineExclusionRuntimeStatus, getGatewayPresets, getPresetEndpoints, listCustomPresets, listPresets, loadPresetForSandbox, } from "."; +import { + BASELINE_EXCLUSION_SUPPORT_IMPACT, + type BaselineExclusionRuntimeStatus, +} from "./baseline-exclusion"; import { hostStemsFromEndpoints } from "./host-redaction"; import { getTier } from "./tiers"; @@ -60,14 +65,44 @@ export interface PolicyContextApprovalPath { inspect: string; add: string; remove: string; + excludeBaseline: string; + restoreBaseline: string; documentation: string; } +export type PolicyContextExclusionStatus = + | BaselineExclusionRuntimeStatus + | "pending-exclude-repair" + | "pending-restore-repair"; + +export interface PolicyContextExclusion { + key: string; + digest: string; + acknowledgedAt: string | null; + /** + * `excluded` — the current baseline still defines this key at the reviewed + * digest and the observed live policy omits it. + * `content-changed` — a release redefined this key's content since + * approval; rebuild fails closed and requires re-approval before the + * exclusion applies again. + * `no-longer-in-baseline` — the current baseline no longer defines this + * key; the exclusion record is inert until restored or replaced. + * `live-policy-*` — live enforcement is unreadable or still contains the + * excluded key, so registry intent must not be treated as enforcement. + * `agent-changed` — the approval belongs to a different agent baseline. + * `pending-*-repair` — the live mutation was interrupted; its durable + * journal blocks rebuild until the exact policy command reconciles it. + */ + status: PolicyContextExclusionStatus; + supportImpact: string; +} + export interface PolicyContext { sandboxName: string; tier: PolicyContextTier | null; activePresets: PolicyContextPreset[]; knownUnappliedPresets: PolicyContextPreset[]; + baselineExclusions: PolicyContextExclusion[]; approvalPath: PolicyContextApprovalPath; supportBoundaries: PolicyContextSupportBoundary[]; generatedAt: string; @@ -166,11 +201,52 @@ function partitionPresets( return { active, unapplied }; } +function buildBaselineExclusions( + sandboxName: string, + transition: registry.BaselineExclusionTransition | null, +): PolicyContextExclusion[] { + const pendingKey = transition?.exclusion.key ?? null; + const byKey = new Map( + registry.getBaselineExclusions(sandboxName).map((exclusion) => { + const status: PolicyContextExclusionStatus = + exclusion.key === pendingKey + ? transition?.operation === "exclude" + ? "pending-exclude-repair" + : "pending-restore-repair" + : getBaselineExclusionRuntimeStatus(sandboxName, exclusion); + return [ + exclusion.key, + { + key: exclusion.key, + digest: exclusion.digest, + acknowledgedAt: exclusion.acknowledgedAt ?? null, + status, + supportImpact: BASELINE_EXCLUSION_SUPPORT_IMPACT, + }, + ] as const; + }), + ); + if (transition) { + const exclusion = transition.exclusion; + byKey.set(exclusion.key, { + key: exclusion.key, + digest: exclusion.digest, + acknowledgedAt: exclusion.acknowledgedAt ?? null, + status: + transition.operation === "exclude" ? "pending-exclude-repair" : "pending-restore-repair", + supportImpact: BASELINE_EXCLUSION_SUPPORT_IMPACT, + }); + } + return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)); +} + function buildApprovalPath(sandboxName: string): PolicyContextApprovalPath { return { - inspect: `nemoclaw ${sandboxName} policy-list`, - add: `nemoclaw ${sandboxName} policy-add `, - remove: `nemoclaw ${sandboxName} policy-remove `, + inspect: `nemoclaw ${sandboxName} policy list`, + add: `nemoclaw ${sandboxName} policy add `, + remove: `nemoclaw ${sandboxName} policy remove `, + excludeBaseline: `nemoclaw ${sandboxName} policy exclude --dry-run`, + restoreBaseline: `nemoclaw ${sandboxName} policy restore `, documentation: POLICY_DOC_URL, }; } @@ -290,6 +366,10 @@ export function buildPolicyContext( tier, activePresets: active.sort((a, b) => a.name.localeCompare(b.name)), knownUnappliedPresets: unapplied.sort((a, b) => a.name.localeCompare(b.name)), + baselineExclusions: buildBaselineExclusions( + sandboxName, + sandbox?.baselineExclusionTransition ?? null, + ), approvalPath: buildApprovalPath(sandboxName), supportBoundaries: buildSupportBoundaries(tier), generatedAt: new Date().toISOString(), @@ -309,6 +389,38 @@ function verificationTag(verification: PolicyContextPresetVerification): string } } +function exclusionStatusTag(status: PolicyContextExclusionStatus): string { + switch (status) { + case "excluded": + return "excluded"; + case "content-changed": + return "content-changed (release redefined this entry; rebuild requires re-approval)"; + case "no-longer-in-baseline": + return "no-longer-in-baseline (record is inert)"; + case "baseline-unreadable": + return "baseline-unreadable (current release scope could not be inspected)"; + case "agent-changed": + return "agent-changed (approval belongs to a different agent baseline)"; + case "live-policy-unreadable": + return "live-policy-unreadable (enforcement could not be inspected)"; + case "live-policy-mismatch": + return "live-policy-mismatch (excluded key remains in the live policy)"; + case "pending-exclude-repair": + return "repair-required (exclude transaction was interrupted; rebuild blocked)"; + case "pending-restore-repair": + return "repair-required (restore transaction was interrupted; rebuild blocked)"; + } +} + +function formatExclusionLine(exclusion: PolicyContextExclusion, sandboxName: string): string { + return [ + `- \`${exclusion.key}\` — status: ${exclusionStatusTag(exclusion.status)}`, + ` acknowledged: ${exclusion.acknowledgedAt ?? "(unknown)"}`, + ` impact: ${exclusion.supportImpact}`, + ` restore: \`nemoclaw ${sandboxName} policy restore ${exclusion.key}\``, + ].join("\n"); +} + function formatPresetLine(preset: PolicyContextPreset): string { const categories = preset.allowedHostCategories.length ? preset.allowedHostCategories.join(", ") @@ -362,10 +474,21 @@ export function renderPolicyContextMarkdown(ctx: PolicyContext): string { } } lines.push(""); + lines.push("## Baseline exclusions"); + if (ctx.baselineExclusions.length === 0) { + lines.push("- none"); + } else { + for (const exclusion of ctx.baselineExclusions) { + lines.push(formatExclusionLine(exclusion, ctx.sandboxName)); + } + } + lines.push(""); lines.push("## Approval and remediation"); lines.push(`- inspect: \`${ctx.approvalPath.inspect}\``); lines.push(`- add a preset: \`${ctx.approvalPath.add}\``); lines.push(`- remove a preset: \`${ctx.approvalPath.remove}\``); + lines.push(`- preview a baseline exclusion: \`${ctx.approvalPath.excludeBaseline}\``); + lines.push(`- restore a baseline entry: \`${ctx.approvalPath.restoreBaseline}\``); lines.push(`- documentation: ${ctx.approvalPath.documentation}`); lines.push(""); lines.push("## Support boundaries"); diff --git a/src/lib/policy/failure-classifier.test.ts b/src/lib/policy/failure-classifier.test.ts index ea150670652..afce73624e6 100644 --- a/src/lib/policy/failure-classifier.test.ts +++ b/src/lib/policy/failure-classifier.test.ts @@ -6,11 +6,13 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../state/registry", () => ({ getSandbox: vi.fn(), getCustomPolicies: vi.fn(() => []), + getBaselineExclusions: vi.fn(() => []), })); vi.mock(".", () => ({ getPresetEndpoints: vi.fn(), getGatewayPresets: vi.fn(() => null), + getSandboxBaselineEntryDigest: vi.fn(() => null), listCustomPresets: vi.fn(), listPresets: vi.fn(), loadPreset: vi.fn(), @@ -23,8 +25,8 @@ vi.mock("./tiers", () => ({ import * as registry from "../state/registry"; import * as policies from "."; -import { getTier } from "./tiers"; import { classifyAccessFailure } from "./failure-classifier"; +import { getTier } from "./tiers"; const SANDBOX = "alpha"; @@ -140,7 +142,7 @@ describe("classifyAccessFailure", () => { expect(result.matchedPreset).toBe("slack"); expect(result.confidence).toBe("low"); expect(result.reason).toContain("drift"); - expect(result.nextStep).toContain("policy-list"); + expect(result.nextStep).toContain("policy list"); }); it("downgrades a matched 401 to low confidence when the gateway is unavailable", () => { @@ -192,7 +194,7 @@ describe("classifyAccessFailure", () => { expect(result.kind).toBe("blocked-by-policy"); expect(result.matchedPreset).toBe("github"); - expect(result.nextStep).toContain("policy-add github"); + expect(result.nextStep).toContain("policy add github"); }); it("returns blocked-by-policy when no preset declares the host and the request is refused", () => { @@ -326,7 +328,7 @@ describe("classifyAccessFailure", () => { expect(result.matchedPreset).toBe("slack"); expect(result.confidence).toBe("low"); expect(result.reason).toContain(code); - expect(result.nextStep).toContain("policy-list"); + expect(result.nextStep).toContain("policy list"); }); it("classifies a gateway-unavailable active-preset host hitting EHOSTUNREACH as blocked-by-policy advisory", () => { diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 0951e78d7a6..7ad71be2767 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -3,6 +3,7 @@ // // Policy preset management — list, load, merge, and apply presets. +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -12,7 +13,7 @@ import YAML from "yaml"; // Namespace access keeps resolveOpenshell spyable in focused policy tests. import * as openshellResolveModule from "../adapters/openshell/resolve"; -import { loadAgent } from "../agent/defs"; +import { loadAgent, requireAgentPolicyAdditionsPath } from "../agent/defs"; import { CLI_NAME } from "../cli/branding"; import { getMessagingPolicyKeyAliases, @@ -23,8 +24,18 @@ import { listMessagingPolicyPresetMetadata, loadMessagingChannelPolicyPreset, } from "../messaging/channels"; +import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; +import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { ROOT, run, runCapture } from "../runner"; import * as registry from "../state/registry"; +import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion"; +import { + digestBaselineEntry, + evaluateBaselineExclusionRuntimeStatus, + getBaselineEntry, + mergeBaselineEntryIntoPolicy, + removeBaselineEntryFromPolicy, +} from "./baseline-exclusion"; import { buildPolicyGetCommand, buildPolicyGetFullCommand, @@ -47,6 +58,7 @@ import { parseNetworkPolicies, } from "./preset-parsing"; import { escapeTerminalText, logPresetScope, renderPresetScope } from "./preset-scope-render"; +import { parseAndValidateSandboxPolicy } from "./sandbox-policy-validation"; import { splitSemanticFindings, validatePolicySemantics } from "./semantic-validation"; const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); @@ -73,6 +85,7 @@ type PresetListOptions = { type MergePresetNamesOptions = { agent?: string | null; + excludedBaselineKeys?: readonly string[]; }; type SetupPolicyPresetSupportOptions = { @@ -188,6 +201,30 @@ function parsePresetPolicyKeysForOwnership(presetContent: string): string[] | nu return networkPolicies === null ? null : Object.keys(networkPolicies); } +function findExcludedBaselineKeyForPolicy( + sandboxName: string, + presetContent: string, +): string | null { + const excludedKeys = new Set( + registry.getBaselineExclusions(sandboxName).map((exclusion) => exclusion.key), + ); + const transition = registry.getBaselineExclusionTransition(sandboxName); + if (transition?.operation === "exclude") excludedKeys.add(transition.exclusion.key); + return parsePresetPolicyKeys(presetContent).find((key) => excludedKeys.has(key)) ?? null; +} + +function findAppliedPolicyOwnerForKey(sandboxName: string, key: string): string | null { + const sandbox = registry.getSandbox(sandboxName); + for (const presetName of sandbox?.policies ?? []) { + const content = loadPresetForSandbox(sandboxName, presetName); + if (content && parsePresetPolicyKeys(content).includes(key)) return presetName; + } + for (const custom of registry.getCustomPolicies(sandboxName)) { + if (parsePresetPolicyKeys(custom.content).includes(key)) return custom.name; + } + return null; +} + const AGENT_PRESET_KEY_ALIASES: Readonly> = getMessagingPolicyKeyAliases(); @@ -466,10 +503,11 @@ function assertOpenshellResolvable(options: { nonFatal?: boolean } = {}): boolea function setPolicyFile( policyFile: string, sandboxName: string, - options: { nonFatal?: boolean } = {}, + options: { nonFatal?: boolean; gatewayName?: string } = {}, ): boolean { const result = run(buildPolicySetCommand(policyFile, sandboxName), { ignoreError: options.nonFatal === true, + ...(options.gatewayName ? { env: { OPENSHELL_GATEWAY: options.gatewayName } } : {}), }); if (!options.nonFatal) return true; if (!result.error && result.status === 0) return true; @@ -640,6 +678,14 @@ function mergePresetNamesIntoPolicy( continue; } + const excludedKeys = new Set(options.excludedBaselineKeys ?? []); + const collision = parsePresetPolicyKeys(presetContent).find((key) => excludedKeys.has(key)); + if (collision) { + throw new Error( + `Cannot compose policy preset '${presetName}': network policy key '${collision}' is reserved by a baseline exclusion. Restore that baseline key before applying the preset.`, + ); + } + merged = mergePresetIntoPolicy(merged, presetEntries); appliedPresets.push(presetName); } @@ -837,6 +883,601 @@ function removePreset( return true; } +/** Push a policy YAML body to a sandbox's live gateway via a private temp file. */ +function pushPolicyYaml( + sandboxName: string, + updatedPolicy: string, + options: { nonFatal?: boolean; gatewayName?: string } = {}, +): boolean { + if (!assertOpenshellResolvable(options)) return false; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); + const tmpFile = path.join(tmpDir, "policy.yaml"); + fs.writeFileSync(tmpFile, updatedPolicy, { encoding: "utf-8", mode: 0o600 }); + try { + return setPolicyFile(tmpFile, sandboxName, options); + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignored */ + } + } +} + +/** Round-trippable live policy body from `--base`, or null when unreadable. */ +function readCurrentSandboxPolicy(sandboxName: string, gatewayName?: string): string | null { + let rawPolicy = ""; + try { + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { + ...(gatewayName ? { env: { OPENSHELL_GATEWAY: gatewayName } } : {}), + }); + } catch { + /* ignored */ + } + return parseCurrentPolicyOrEmpty(rawPolicy) || null; +} + +/** Resolve and validate one agent's reviewed baseline policy source. */ +function resolveAgentBaselinePolicy( + agentName: string | null | undefined, +): { agent: string; policyPath: string; content: string } | null { + const resolvedAgent = agentName || "openclaw"; + const usesOpenClawBaseline = !agentName || agentName === "openclaw"; + const policyPath = usesOpenClawBaseline + ? path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml") + : requireAgentPolicyAdditionsPath(loadAgent(resolvedAgent)); + let content: string; + try { + content = fs.readFileSync(policyPath, "utf-8"); + } catch { + if (!usesOpenClawBaseline) { + throw new Error( + `Agent '${resolvedAgent}' baseline policy became unreadable. Refusing to substitute the OpenClaw baseline.`, + ); + } + return null; + } + parseAndValidateSandboxPolicy(content); + return { agent: resolvedAgent, policyPath, content }; +} + +/** Resolve the reviewed baseline policy source recorded for a sandbox. */ +function resolveSandboxBaselinePolicy( + sandboxName: string, +): { agent: string; policyPath: string; content: string } | null { + return resolveAgentBaselinePolicy(registry.getSandbox(sandboxName)?.agent); +} + +/** The current baseline entry for a key, or null when the baseline omits it. */ +function getSandboxBaselineEntry(sandboxName: string, key: string): PolicyObject | null { + const baseline = resolveSandboxBaselinePolicy(sandboxName); + return baseline ? getBaselineEntry(baseline.content, key) : null; +} + +/** Content digest of a sandbox's current baseline entry, or null when absent. */ +function getSandboxBaselineEntryDigest(sandboxName: string, key: string): string | null { + const entry = getSandboxBaselineEntry(sandboxName, key); + return entry ? digestBaselineEntry(entry) : null; +} + +/** Digest of an observed live policy key, null when absent, or throw when unreadable. */ +function getLiveSandboxPolicyEntryDigest(sandboxName: string, key: string): string | null { + assertNoOpenShellGatewayEndpointOverride(); + const sandbox = registry.getSandbox(sandboxName); + if (!sandbox) throw new Error(`Sandbox '${sandboxName}' is not registered.`); + const gatewayName = resolveSandboxGatewayName(sandbox); + const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); + if (!currentPolicy) throw new Error(`Live policy for '${sandboxName}' is unreadable.`); + const live = inspectLiveBaselineEntry(currentPolicy, key); + if (live.state === "invalid") { + throw new Error(`Live policy key '${key}' for '${sandboxName}' is malformed.`); + } + return live.digest; +} + +/** Three-way status across agent source, reviewed baseline, and observed live policy. */ +function getBaselineExclusionRuntimeStatus( + sandboxName: string, + exclusion: registry.BaselineExclusionEntry, +): BaselineExclusionRuntimeStatus { + const currentAgent = registry.getSandbox(sandboxName)?.agent || "openclaw"; + if (exclusion.agent !== currentAgent) return "agent-changed"; + let currentBaselineDigest: string | null; + try { + currentBaselineDigest = getSandboxBaselineEntryDigest(sandboxName, exclusion.key); + } catch { + return "baseline-unreadable"; + } + const baselineStatus = evaluateBaselineExclusionRuntimeStatus( + exclusion, + currentAgent, + currentBaselineDigest, + undefined, + ); + if (baselineStatus !== "live-policy-unreadable") return baselineStatus; + try { + const liveDigest = getLiveSandboxPolicyEntryDigest(sandboxName, exclusion.key); + return evaluateBaselineExclusionRuntimeStatus( + exclusion, + currentAgent, + currentBaselineDigest, + liveDigest, + ); + } catch { + return "live-policy-unreadable"; + } +} + +/** Run one baseline transaction against the sandbox's durable gateway binding. */ +function withRecordedSandboxGateway( + sandboxName: string, + operation: (gatewayName: string) => boolean, +): boolean { + assertNoOpenShellGatewayEndpointOverride(); + const sandbox = registry.getSandbox(sandboxName); + if (!sandbox) { + console.error(` Sandbox '${sandboxName}' is not registered; no policy changes were made.`); + return false; + } + const gatewayName = resolveSandboxGatewayName(sandbox); + // Never rewrite process.env here: two sandbox operations may run in the + // same CLI process. Every live read/write receives this binding explicitly. + return operation(gatewayName); +} + +type BaselineTransitionReconciliation = + | { state: "none" } + | { state: "excluded" | "restored" } + | { state: "resume"; transition: registry.BaselineExclusionTransition }; + +function registryTransitionStep(action: () => boolean, failureMessage: string): boolean { + try { + if (action()) return true; + } catch { + // The durable journal remains authoritative; do not hide it with a second + // best-effort mutation after a persistence exception. + } + console.error(` ${failureMessage}`); + return false; +} + +type LiveBaselineEntryState = + | { state: "absent"; digest: null } + | { state: "present"; digest: string } + | { state: "invalid"; digest: null }; + +function inspectLiveBaselineEntry(policy: string, key: string): LiveBaselineEntryState { + try { + const document = YAML.parse(policy); + if (!isPolicyDocument(document)) { + return { state: "invalid", digest: null }; + } + if (document.network_policies === undefined || document.network_policies === null) { + return { state: "absent", digest: null }; + } + if (!isPolicyObject(document.network_policies)) return { state: "invalid", digest: null }; + if (!Object.prototype.hasOwnProperty.call(document.network_policies, key)) { + return { state: "absent", digest: null }; + } + const entry = document.network_policies[key]; + return isPolicyObject(entry) + ? { state: "present", digest: digestBaselineEntry(entry) } + : { state: "invalid", digest: null }; + } catch { + return { state: "invalid", digest: null }; + } +} + +/** + * Recover an interrupted registry/live-policy transaction from exact live + * state. The journal is finalized only at its exact target and rolled back + * only at its exact source; any third state remains visible and fail-closed. + */ +function reconcileBaselineExclusionTransition( + sandboxName: string, + requestedKey: string, + gatewayName: string, +): BaselineTransitionReconciliation | null { + const transition = registry.getBaselineExclusionTransition(sandboxName); + if (!transition) return { state: "none" }; + const key = transition.exclusion.key; + if (key !== requestedKey) { + console.error( + ` Baseline policy repair for '${key}' is still pending. Re-run 'policy ${transition.operation} ${key}' before changing another baseline entry.`, + ); + return null; + } + if (transition.operation === "restore") { + const committed = registry + .getBaselineExclusions(sandboxName) + .find((entry) => entry.key === transition.exclusion.key); + if (!committed || !isDeepStrictEqual(committed, transition.exclusion)) { + console.error( + ` The durable exclusion for '${key}' changed during the pending restore. The journal was preserved; inspect registry intent before retrying.`, + ); + return null; + } + } + const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); + if (!currentPolicy) { + console.error( + ` Could not inspect the live policy needed to repair the pending '${transition.operation}' for '${key}'. The journal remains pending and rebuild is blocked.`, + ); + return null; + } + const live = inspectLiveBaselineEntry(currentPolicy, key); + const atTarget = + transition.targetLiveDigest === null + ? live.state === "absent" + : live.state === "present" && live.digest === transition.targetLiveDigest; + if (atTarget) { + if (!finalizeBaselineExclusionTransition(sandboxName, transition)) return null; + return { state: transition.operation === "exclude" ? "excluded" : "restored" }; + } + + const atSource = + transition.operation === "exclude" + ? live.state === "present" && live.digest === transition.exclusion.digest + : live.state === "absent"; + if (atSource) { + const committed = registry + .getBaselineExclusions(sandboxName) + .some((entry) => entry.key === transition.exclusion.key); + // A re-exclude can begin from a pre-existing inconsistent record. Preserve + // its journal and resume the exact live mutation instead of hiding that + // divergence by returning to the already-inconsistent committed state. + if (transition.operation === "exclude" && committed) { + return { state: "resume", transition }; + } + if ( + !registryTransitionStep( + () => registry.clearBaselineExclusionTransition(sandboxName, transition.id), + `The live policy remains at the pre-${transition.operation} state for '${key}', but the durable journal could not be rolled back. Re-run the same command; rebuild remains blocked.`, + ) + ) { + return null; + } + return { state: transition.operation === "exclude" ? "restored" : "excluded" }; + } + + console.error( + ` Live baseline entry '${key}' matches neither side of the pending '${transition.operation}' transaction. The journal was preserved; inspect the live policy and repair it before rebuilding.`, + ); + return null; +} + +function beginBaselineExclusionTransition( + sandboxName: string, + operation: registry.BaselineExclusionTransitionOperation, + exclusion: registry.BaselineExclusionEntry, + targetLiveDigest: string | null, +): registry.BaselineExclusionTransition | null { + const transition: registry.BaselineExclusionTransition = { + id: randomUUID(), + operation, + exclusion, + targetLiveDigest, + startedAt: new Date().toISOString(), + }; + return registryTransitionStep( + () => registry.beginBaselineExclusionTransition(sandboxName, transition), + `Could not record the pending baseline '${operation}' for '${sandboxName}'; no live policy changes were made.`, + ) + ? transition + : null; +} + +function restoreTransitionCanFinalize( + sandboxName: string, + transition: registry.BaselineExclusionTransition, +): boolean { + if (transition.operation !== "restore") return true; + const committed = registry + .getBaselineExclusions(sandboxName) + .find((entry) => entry.key === transition.exclusion.key); + if (!committed || !isDeepStrictEqual(committed, transition.exclusion)) { + console.error( + ` The durable exclusion for '${transition.exclusion.key}' no longer matches the pending restore. The journal was preserved; rebuild remains blocked.`, + ); + return false; + } + let currentBaselineDigest: string | null; + try { + currentBaselineDigest = getSandboxBaselineEntryDigest(sandboxName, transition.exclusion.key); + } catch { + console.error( + ` The current release baseline for '${transition.exclusion.key}' is unreadable. The pending restore was not finalized; rebuild remains blocked.`, + ); + return false; + } + if (currentBaselineDigest !== transition.targetLiveDigest) { + console.error( + ` The current release baseline for '${transition.exclusion.key}' changed during the pending restore. The journal was preserved; re-review the current scope before repairing it.`, + ); + return false; + } + return true; +} + +function finalizeBaselineExclusionTransition( + sandboxName: string, + transition: registry.BaselineExclusionTransition, +): boolean { + if (!restoreTransitionCanFinalize(sandboxName, transition)) return false; + return registryTransitionStep( + () => registry.commitBaselineExclusionTransition(sandboxName, transition.id), + `The live policy was updated for '${transition.exclusion.key}', but the durable journal could not be finalized. Re-run 'policy ${transition.operation} ${transition.exclusion.key}' to reconcile it; rebuild remains blocked.`, + ); +} + +function compensateBaselineExclusionTransition( + sandboxName: string, + transition: registry.BaselineExclusionTransition, +): boolean { + return registryTransitionStep( + () => registry.clearBaselineExclusionTransition(sandboxName, transition.id), + `Failed to roll back the pending baseline '${transition.operation}' for '${transition.exclusion.key}'. The durable journal was preserved; re-run the same command before rebuilding '${sandboxName}'.`, + ); +} + +function settleBaselineExclusionTransitionAfterPush( + sandboxName: string, + transition: registry.BaselineExclusionTransition, + pushSucceeded: boolean, + canRollbackAtSource: boolean, + gatewayName: string, +): boolean { + const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); + if (!currentPolicy) { + console.error( + ` Could not verify the live '${transition.operation}' result for '${transition.exclusion.key}'. The durable journal was preserved and rebuild remains blocked.`, + ); + return false; + } + const live = inspectLiveBaselineEntry(currentPolicy, transition.exclusion.key); + const atTarget = + transition.targetLiveDigest === null + ? live.state === "absent" + : live.state === "present" && live.digest === transition.targetLiveDigest; + if (atTarget) { + return finalizeBaselineExclusionTransition(sandboxName, transition); + } + const atSource = + transition.operation === "exclude" + ? live.state === "present" && live.digest === transition.exclusion.digest + : live.state === "absent"; + if (!pushSucceeded && atSource && canRollbackAtSource) { + compensateBaselineExclusionTransition(sandboxName, transition); + return false; + } + const state = atSource ? "the pre-mutation state" : "an unexpected third state"; + console.error( + ` Live baseline entry '${transition.exclusion.key}' is in ${state} after the '${transition.operation}' attempt. The durable journal was preserved; re-run the same command before rebuilding.`, + ); + return false; +} + +function attemptBaselineTransitionPolicyPush( + sandboxName: string, + updatedPolicy: string, + options: { nonFatal?: boolean }, + gatewayName: string, +): boolean { + try { + return pushPolicyYaml(sandboxName, updatedPolicy, { + ...options, + nonFatal: true, + gatewayName, + }); + } catch { + console.error( + ` The live policy update for '${sandboxName}' raised an unexpected error; verifying the journal before deciding whether it applied.`, + ); + return false; + } +} + +/** + * Exclude a baseline entry from the running sandbox policy and record the + * approval, bound to `digest`, in the registry so create/rebuild replay it. + */ +function excludeBaselineEntry( + sandboxName: string, + key: string, + digest: string, + options: { nonFatal?: boolean } = {}, +): boolean { + return withRecordedSandboxGateway(sandboxName, (gatewayName) => + excludeBaselineEntryOnGateway(sandboxName, key, digest, options, gatewayName), + ); +} + +function excludeBaselineEntryOnGateway( + sandboxName: string, + key: string, + digest: string, + options: { nonFatal?: boolean }, + gatewayName: string, +): boolean { + const reconciled = reconcileBaselineExclusionTransition(sandboxName, key, gatewayName); + if (!reconciled) return false; + if (reconciled.state === "excluded") return true; + if (reconciled.state === "resume" && reconciled.transition.operation !== "exclude") { + console.error(` Finish the pending baseline restore for '${key}' before excluding it again.`); + return false; + } + const appliedOwner = findAppliedPolicyOwnerForKey(sandboxName, key); + if (appliedOwner) { + console.error( + ` Baseline entry '${key}' is also owned by applied policy '${appliedOwner}'. Remove that policy before excluding the baseline key; no policy changes were made.`, + ); + return false; + } + const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); + if (!currentPolicy) { + console.error(` Could not read current policy for sandbox '${sandboxName}'.`); + return false; + } + const live = inspectLiveBaselineEntry(currentPolicy, key); + if (live.state === "invalid") { + console.error( + ` Live baseline entry '${key}' could not be classified safely; no policy changes were made.`, + ); + return false; + } + if (live.state === "present" && live.digest !== digest) { + console.error( + ` Baseline entry '${key}' changed after preview. Re-run the command to review its current scope; no policy changes were made.`, + ); + return false; + } + const { policy: updated, removed } = removeBaselineEntryFromPolicy(currentPolicy, key); + const previousExclusion = registry + .getBaselineExclusions(sandboxName) + .find((entry) => entry.key === key); + const sandbox = registry.getSandbox(sandboxName); + const appliedAgentVersion = sandbox?.agentVersion ?? null; + const exclusion: registry.BaselineExclusionEntry = { + version: 1, + agent: sandbox?.agent || "openclaw", + key, + digest, + acknowledgedAt: new Date().toISOString(), + appliedAgentVersion, + }; + if (!removed) { + if (reconciled.state === "resume") { + return finalizeBaselineExclusionTransition(sandboxName, reconciled.transition); + } + return registryTransitionStep( + () => registry.addBaselineExclusion(sandboxName, exclusion), + `The already-narrow live policy could not be recorded for '${sandboxName}'.`, + ); + } + const transition = + reconciled.state === "resume" + ? reconciled.transition + : beginBaselineExclusionTransition(sandboxName, "exclude", exclusion, null); + if (!transition) return false; + const pushSucceeded = attemptBaselineTransitionPolicyPush( + sandboxName, + updated, + options, + gatewayName, + ); + // When this was a fresh exclusion, a failed push that verifies at the exact + // source can clear the journal. A re-exclude that began with committed/live + // divergence must retain it until the live side reaches the target. + return settleBaselineExclusionTransitionAfterPush( + sandboxName, + transition, + pushSucceeded, + !previousExclusion, + gatewayName, + ); +} + +/** + * Restore a previously excluded baseline entry against the current release + * baseline and drop its recorded exclusion. When the release removed the entry + * entirely, only the registry record is cleared. + */ +function restoreBaselineEntry( + sandboxName: string, + key: string, + options: { nonFatal?: boolean } = {}, +): boolean { + return withRecordedSandboxGateway(sandboxName, (gatewayName) => + restoreBaselineEntryOnGateway(sandboxName, key, options, gatewayName), + ); +} + +function restoreBaselineEntryOnGateway( + sandboxName: string, + key: string, + options: { nonFatal?: boolean }, + gatewayName: string, +): boolean { + const reconciled = reconcileBaselineExclusionTransition(sandboxName, key, gatewayName); + if (!reconciled) return false; + if (reconciled.state === "restored") return true; + if (reconciled.state === "resume" && reconciled.transition.operation !== "restore") { + console.error(` Finish the pending baseline exclusion for '${key}' before restoring it.`); + return false; + } + const recordedExclusion = registry + .getBaselineExclusions(sandboxName) + .find((entry) => entry.key === key); + if (!recordedExclusion) { + console.error( + ` The exclusion for '${key}' is not recorded; no live policy changes were made.`, + ); + return false; + } + // Resolve the current agent baseline before changing either durable or live + // state. A missing non-OpenClaw baseline must not be mistaken for a release + // that intentionally removed this key. + const entry = getSandboxBaselineEntry(sandboxName, key); + const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); + if (!currentPolicy) { + console.error(` Could not read current policy for sandbox '${sandboxName}'.`); + return false; + } + if (!entry) { + return registryTransitionStep( + () => registry.removeBaselineExclusion(sandboxName, key), + `The obsolete exclusion for '${key}' could not be cleared; no live policy changes were made.`, + ); + } + const targetDigest = digestBaselineEntry(entry); + const live = inspectLiveBaselineEntry(currentPolicy, key); + if (live.state === "invalid") { + console.error( + ` Live baseline entry '${key}' could not be classified safely; no policy changes were made.`, + ); + return false; + } + if (live.state === "present" && live.digest !== targetDigest) { + console.error( + ` Live baseline entry '${key}' differs from the current release baseline. Refusing to overwrite it; repair the live policy before restoring this exclusion.`, + ); + return false; + } + if (live.state === "present" && live.digest === targetDigest) { + if (reconciled.state === "resume") { + return finalizeBaselineExclusionTransition(sandboxName, reconciled.transition); + } + return registryTransitionStep( + () => registry.removeBaselineExclusion(sandboxName, key), + `The restored live policy could not be recorded for '${sandboxName}'.`, + ); + } + const transition = + reconciled.state === "resume" + ? reconciled.transition + : beginBaselineExclusionTransition(sandboxName, "restore", recordedExclusion, targetDigest); + if (!transition) return false; + const updated = mergeBaselineEntryIntoPolicy(currentPolicy, key, entry); + const pushSucceeded = attemptBaselineTransitionPolicyPush( + sandboxName, + updated, + options, + gatewayName, + ); + return settleBaselineExclusionTransitionAfterPush( + sandboxName, + transition, + pushSucceeded, + true, + gatewayName, + ); +} + /** * Ask one preset-picker question on stderr and resolve to the raw answer. * @@ -988,6 +1629,13 @@ function applyPresetContent( console.error(` Preset ${presetName} has no network_policies section.`); return false; } + const excludedCollision = findExcludedBaselineKeyForPolicy(sandboxName, presetContent); + if (excludedCollision) { + console.error( + ` Network policy key '${excludedCollision}' is reserved by a baseline exclusion. Restore that baseline key before applying '${presetName}'.`, + ); + return false; + } // Get current policy YAML from sandbox let rawPolicy: string | null = null; @@ -1109,7 +1757,7 @@ function applyPresetContent( console.error( ` Warning: '${presetName}' was applied to the gateway but could not be ` + `recorded locally because sandbox '${sandboxName}' is not in the ` + - `registry, so it will not appear in policy-list or status. Recover or ` + + `registry, so it will not appear in policy list or status. Recover or ` + `re-onboard the sandbox, then re-apply.`, ); return false; @@ -1191,6 +1839,13 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { console.error(` Preset ${presetName} has no network_policies section.`); return false; } + const excludedCollision = findExcludedBaselineKeyForPolicy(sandboxName, presetContent); + if (excludedCollision) { + console.error( + ` Network policy key '${excludedCollision}' is reserved by a baseline exclusion. Restore that baseline key before applying '${presetName}'.`, + ); + return false; + } const state = classifyPresetEntries(merged, presetEntries); presetContents.push({ content: presetContent, name: presetName, state }); @@ -1524,7 +2179,7 @@ function presetContentMatchesGateway(sandboxName: string, presetContent: string) } /** - * Interactive preset picker for the `policy-add` command. Prints the + * Interactive preset picker for the `policy add` command. Prints the * presets on stderr (● applied, ○ not applied), prompts for a number, and * resolves to the chosen preset name or `null` on cancel. Rejects with * `code: "EOF"` when stdin closes before an answer (see `askPreset`). @@ -1557,10 +2212,10 @@ async function selectFromList( } if (applied.includes(item.name)) { // The picker has no live-policy context to classify drift; the named - // path (policy-add ) re-applies edited presets (#7323). + // path (`policy add `) re-applies edited presets (#7323). process.stderr.write(`\n Preset '${item.name}' is already applied.\n`); process.stderr.write( - ` If its preset file changed, run '${CLI_NAME} policy-add ${item.name}' to re-apply it.\n`, + ` If its preset file changed, run '${CLI_NAME} policy add ${item.name}' to re-apply it.\n`, ); return null; } @@ -1628,13 +2283,18 @@ export { buildPolicySetCommand, clampSetupPolicyPresetNames, customPresetOwnsNetworkPolicyKey, + excludeBaselineEntry, extractPresetEntries, filterSetupPolicyPresets, getAppliedPresets, + getBaselineExclusionRuntimeStatus, getGatewayPresets, + getLiveSandboxPolicyEntryDigest, getPresetContentGatewayState, getPresetEndpoints, getPresetValidationWarning, + getSandboxBaselineEntry, + getSandboxBaselineEntryDigest, isMessagingChannelPolicyPreset, listCustomPresets, listPresets, @@ -1657,7 +2317,10 @@ export { removePreset, removePresetFromPolicy, renderPresetScope, + resolveAgentBaselinePolicy, resolvePermissivePolicyPath, + resolveSandboxBaselinePolicy, + restoreBaselineEntry, selectForRemoval, selectFromList, setupPolicyPresetSupported, diff --git a/src/lib/policy/sandbox-policy-validation.ts b/src/lib/policy/sandbox-policy-validation.ts new file mode 100644 index 00000000000..22301d341fe --- /dev/null +++ b/src/lib/policy/sandbox-policy-validation.ts @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import type { AnySchemaObject, ErrorObject, ValidateFunction } from "ajv"; +import Ajv from "ajv/dist/2020.js"; + +import { parseOpenShellPolicy } from "./merge"; + +const PACKAGE_ROOT = path.resolve(__dirname, "..", "..", ".."); +const SANDBOX_POLICY_SCHEMA_PATH = path.join(PACKAGE_ROOT, "schemas", "sandbox-policy.schema.json"); +const MAX_SCHEMA_ERRORS = 3; +const MAX_SCHEMA_ERROR_MESSAGE_CHARS = 120; +const MAX_SCHEMA_ERROR_SUMMARY_CHARS = 500; + +let cachedSandboxPolicyValidator: ValidateFunction | null = null; + +function loadSandboxPolicyValidator(): ValidateFunction { + if (cachedSandboxPolicyValidator) return cachedSandboxPolicyValidator; + + let schema: AnySchemaObject; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(SANDBOX_POLICY_SCHEMA_PATH, "utf-8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("schema root is not an object"); + } + schema = parsed as AnySchemaObject; + } catch { + throw new Error( + "Sandbox policy validation schema is unavailable from this NemoClaw installation.", + ); + } + + try { + const compiled = new Ajv({ allErrors: true, strict: false, $data: true }).compile(schema); + cachedSandboxPolicyValidator = compiled; + return compiled; + } catch { + throw new Error("Sandbox policy schema validation could not be initialized."); + } +} + +function boundedSchemaErrorSummary(errors: ErrorObject[] | null | undefined): string { + if (!errors || errors.length === 0) return "schema validation failed"; + + // AJV's keyword and message describe the trusted schema, not the rejected + // input value. Deliberately omit instancePath, params, and data so a + // user-controlled policy key or scalar can never enter rebuild diagnostics. + const details = errors.slice(0, MAX_SCHEMA_ERRORS).map((error) => { + const message = (error.message ?? "validation failed") + .replace(/[\r\n\t]+/gu, " ") + .slice(0, MAX_SCHEMA_ERROR_MESSAGE_CHARS); + return `${error.keyword}: ${message}`; + }); + if (errors.length > MAX_SCHEMA_ERRORS) { + details.push(`${String(errors.length - MAX_SCHEMA_ERRORS)} more error(s)`); + } + return details.join("; ").slice(0, MAX_SCHEMA_ERROR_SUMMARY_CHARS); +} + +/** Parse and validate a complete sandbox policy without admitting input values to errors. */ +export function parseAndValidateSandboxPolicy(content: string): Record { + let policy: Record; + try { + policy = parseOpenShellPolicy(content).policy; + } catch { + throw new Error("Sandbox policy is malformed or is not an OpenShell policy YAML mapping."); + } + + const validate = loadSandboxPolicyValidator(); + if (!validate(policy)) { + throw new Error( + `Sandbox policy does not satisfy the shipped sandbox policy schema (${boundedSchemaErrorSummary(validate.errors)}).`, + ); + } + return policy; +} diff --git a/src/lib/sandbox/policy-command-support.ts b/src/lib/sandbox/policy-command-support.ts index 0b8941c3579..fdcc4d33113 100644 --- a/src/lib/sandbox/policy-command-support.ts +++ b/src/lib/sandbox/policy-command-support.ts @@ -4,6 +4,7 @@ import { Args } from "@oclif/core"; import { dryRunFlag, forceFlag, yesFlag } from "../cli/common-flags"; + const sandboxNameArg = Args.string({ name: "sandbox", description: "Sandbox name", @@ -16,6 +17,12 @@ const presetArg = Args.string({ ignoreStdin: true, required: false, }); +const baselineKeyArg = Args.string({ + name: "key", + description: "Exact baseline network policy key", + ignoreStdin: true, + required: true, +}); export function commonPolicyOptions(flags: { yes?: boolean; @@ -31,8 +38,12 @@ export function commonPolicyOptions(flags: { export const policyMutationArgs = { sandboxName: sandboxNameArg, preset: presetArg }; +export const policyBaselineArgs = { sandboxName: sandboxNameArg, key: baselineKeyArg }; + export const policyMutationFlags = { yes: yesFlag(), force: forceFlag(), "dry-run": dryRunFlag(), }; + +export const policyBaselineRestoreFlags = { "dry-run": dryRunFlag() }; diff --git a/src/lib/state/registry-normalization.test.ts b/src/lib/state/registry-normalization.test.ts index 6287fb78ba9..7068c750c2f 100644 --- a/src/lib/state/registry-normalization.test.ts +++ b/src/lib/state/registry-normalization.test.ts @@ -7,6 +7,11 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + normalizeBaselineExclusions, + normalizeBaselineExclusionTransition, +} from "./registry-normalization"; + const originalHome = process.env.HOME; const temporaryHomes: string[] = []; @@ -115,3 +120,295 @@ describe("sandbox registry normalization", () => { expect(persisted.defaultSelectionRevision).toBe(1); }); }); + +describe("baseline exclusion normalization (#7178)", () => { + const digest = "a".repeat(64); + const entry = { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest, + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }; + + it("keeps an exact versioned, agent-bound entry", () => { + expect( + normalizeBaselineExclusions([ + { + ...entry, + appliedAgentVersion: "1", + }, + ]), + ).toEqual([{ ...entry, appliedAgentVersion: "1" }]); + }); + + it("preserves an explicitly unknown applied agent version", () => { + expect(normalizeBaselineExclusions([{ ...entry, appliedAgentVersion: null }])).toEqual([ + { ...entry, appliedAgentVersion: null }, + ]); + }); + + it("fails closed when any persisted record is malformed", () => { + expect(() => normalizeBaselineExclusions([entry, { ...entry, key: "" }])).toThrow( + /invalid versioned baseline exclusion.*before rebuilding/i, + ); + expect(() => normalizeBaselineExclusions(["not-an-object"])).toThrow( + /malformed baseline exclusion.*before rebuilding/i, + ); + }); + + it("rejects an unversioned record so its baseline source is never guessed (#7194)", () => { + expect(() => normalizeBaselineExclusions([{ key: entry.key, digest }])).toThrow( + /invalid versioned baseline exclusion.*before rebuilding/i, + ); + }); + + it("collapses duplicate keys, last wins", () => { + expect( + normalizeBaselineExclusions([ + { ...entry, key: "dup", digest: "b".repeat(64) }, + { ...entry, key: "dup", digest: "c".repeat(64) }, + ]), + ).toEqual([{ ...entry, key: "dup", digest: "c".repeat(64) }]); + }); + + it("returns undefined only for a legacy registry without the field", () => { + expect(normalizeBaselineExclusions(undefined)).toBeUndefined(); + expect(normalizeBaselineExclusions([])).toBeUndefined(); + expect(() => normalizeBaselineExclusions("nope")).toThrow(/must be an array/i); + expect(() => normalizeBaselineExclusions([{ ...entry, key: "" }])).toThrow( + /invalid versioned baseline exclusion/i, + ); + }); +}); + +describe("baseline exclusion transition normalization (#7178)", () => { + const sourceDigest = "a".repeat(64); + const targetDigest = "b".repeat(64); + const restoreTransition = { + id: "123e4567-e89b-42d3-a456-426614174000", + operation: "restore" as const, + exclusion: { version: 1 as const, agent: "hermes", key: "nous_research", digest: sourceDigest }, + targetLiveDigest: targetDigest, + startedAt: "2026-07-19T00:00:00.000Z", + }; + + it("preserves an exact well-formed journal", () => { + expect(normalizeBaselineExclusionTransition(restoreTransition)).toEqual(restoreTransition); + expect(normalizeBaselineExclusionTransition(undefined)).toBeUndefined(); + }); + + it("fails closed for partial operations or invalid live targets", () => { + expect(() => + normalizeBaselineExclusionTransition({ ...restoreTransition, operation: "unknown" }), + ).toThrow(/incomplete baseline exclusion transition.*before rebuilding/i); + expect(() => + normalizeBaselineExclusionTransition({ ...restoreTransition, targetLiveDigest: null }), + ).toThrow(/invalid live target.*before rebuilding/i); + expect(() => + normalizeBaselineExclusionTransition({ + ...restoreTransition, + operation: "exclude", + targetLiveDigest: "must-be-absent", + }), + ).toThrow(/invalid live target.*before rebuilding/i); + }); + + it.each([ + ["non-UUID id", { id: "tx-1" }], + ["non-canonical timestamp", { startedAt: "yesterday" }], + [ + "unsafe key", + { exclusion: { version: 1, agent: "hermes", key: "bad key\nnext", digest: sourceDigest } }, + ], + [ + "non-SHA source digest", + { exclusion: { version: 1, agent: "hermes", key: "nous_research", digest: "short" } }, + ], + ["non-SHA target digest", { targetLiveDigest: "short" }], + ])("rejects a journal with %s (#7178)", (_label, override) => { + expect(() => + normalizeBaselineExclusionTransition({ ...restoreTransition, ...override }), + ).toThrow( + /(?:baseline exclusion transition|invalid versioned baseline exclusion).*before rebuilding/i, + ); + }); +}); + +describe("baseline exclusion registry helpers (#7178)", () => { + it("round-trips add, get, and remove keyed by baseline entry", async () => { + const registry = await loadRegistryWith({}); + registry.registerSandbox({ name: "alpha", agent: "hermes" }); + + expect(registry.getBaselineExclusions("alpha")).toEqual([]); + + expect( + registry.addBaselineExclusion("alpha", { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: "d".repeat(64), + appliedAgentVersion: null, + }), + ).toBe(true); + const stored = registry.getBaselineExclusions("alpha"); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ + key: "nous_research", + digest: "d".repeat(64), + appliedAgentVersion: null, + }); + expect(typeof stored[0].acknowledgedAt).toBe("string"); + + expect(registry.removeBaselineExclusion("alpha", "nous_research")).toBe(true); + expect(registry.getBaselineExclusions("alpha")).toEqual([]); + expect(registry.removeBaselineExclusion("alpha", "nous_research")).toBe(false); + }); + + it("keeps exclusions independent from a same-named custom preset", async () => { + const registry = await loadRegistryWith({}); + registry.registerSandbox({ name: "alpha", agent: "hermes" }); + + registry.addCustomPolicy("alpha", { name: "brave", content: "version: 1\n" }); + registry.addBaselineExclusion("alpha", { + version: 1, + agent: "hermes", + key: "brave", + digest: "d".repeat(64), + }); + + expect(registry.getCustomPolicies("alpha").map((p) => p.name)).toEqual(["brave"]); + expect(registry.getBaselineExclusions("alpha").map((e) => e.key)).toEqual(["brave"]); + + registry.removeBaselineExclusion("alpha", "brave"); + expect(registry.getCustomPolicies("alpha").map((p) => p.name)).toEqual(["brave"]); + expect(registry.getBaselineExclusions("alpha")).toEqual([]); + }); + + it("journals and atomically commits an exclude or restore transition", async () => { + const registry = await loadRegistryWith({}); + registry.registerSandbox({ name: "alpha", agent: "hermes" }); + const exclude = { + id: "123e4567-e89b-42d3-a456-426614174001", + operation: "exclude" as const, + exclusion: { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:01.000Z", + }; + + expect(registry.beginBaselineExclusionTransition("alpha", exclude)).toBe(true); + expect( + registry.beginBaselineExclusionTransition("alpha", { + ...exclude, + id: "123e4567-e89b-42d3-a456-426614174002", + }), + ).toBe(false); + expect(registry.getBaselineExclusionTransition("alpha")).toEqual(exclude); + expect(registry.getBaselineExclusions("alpha")).toEqual([]); + expect(registry.commitBaselineExclusionTransition("alpha", "wrong-id")).toBe(false); + expect(registry.commitBaselineExclusionTransition("alpha", exclude.id)).toBe(true); + expect(registry.getBaselineExclusionTransition("alpha")).toBeNull(); + expect(registry.getBaselineExclusions("alpha")).toEqual([exclude.exclusion]); + + const restore = { + id: "123e4567-e89b-42d3-a456-426614174003", + operation: "restore" as const, + exclusion: exclude.exclusion, + targetLiveDigest: "b".repeat(64), + startedAt: "2026-07-19T00:00:02.000Z", + }; + expect(registry.beginBaselineExclusionTransition("alpha", restore)).toBe(true); + expect(registry.commitBaselineExclusionTransition("alpha", restore.id)).toBe(true); + expect(registry.getBaselineExclusions("alpha")).toEqual([]); + expect(registry.getBaselineExclusionTransition("alpha")).toBeNull(); + }); + + it("clears only the exact journal without changing committed exclusions", async () => { + const registry = await loadRegistryWith({}); + registry.registerSandbox({ + name: "alpha", + baselineExclusions: [ + { version: 1, agent: "hermes", key: "nous_research", digest: "d".repeat(64) }, + ], + }); + const transition = { + id: "123e4567-e89b-42d3-a456-426614174004", + operation: "restore" as const, + exclusion: { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: "a".repeat(64), + }, + targetLiveDigest: "b".repeat(64), + startedAt: "2026-07-19T00:00:02.000Z", + }; + expect(registry.beginBaselineExclusionTransition("alpha", transition)).toBe(true); + expect( + registry.addBaselineExclusion("alpha", { + version: 1, + agent: "hermes", + key: "other", + digest: "e".repeat(64), + }), + ).toBe(false); + expect(registry.removeBaselineExclusion("alpha", "nous_research")).toBe(false); + expect(registry.clearBaselineExclusionTransition("alpha", "wrong-id")).toBe(false); + expect(registry.clearBaselineExclusionTransition("alpha", transition.id)).toBe(true); + expect(registry.getBaselineExclusions("alpha")).toEqual([ + expect.objectContaining({ key: "nous_research", digest: "d".repeat(64) }), + ]); + }); + + it("preserves a restore journal when the committed exclusion changed (#7178)", async () => { + const source = { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }; + const registry = await loadRegistryWith({}); + registry.registerSandbox({ name: "alpha", baselineExclusions: [source] }); + const transition = { + id: "123e4567-e89b-42d3-a456-426614174005", + operation: "restore" as const, + exclusion: source, + targetLiveDigest: "b".repeat(64), + startedAt: "2026-07-19T00:00:01.000Z", + }; + expect(registry.beginBaselineExclusionTransition("alpha", transition)).toBe(true); + + const document = registry.load(); + document.sandboxes.alpha.baselineExclusions = [{ ...source, digest: "c".repeat(64) }]; + registry.save(document); + + expect(registry.commitBaselineExclusionTransition("alpha", transition.id)).toBe(false); + expect(registry.getBaselineExclusionTransition("alpha")).toEqual(transition); + expect(registry.getBaselineExclusions("alpha")).toEqual([ + { ...source, digest: "c".repeat(64) }, + ]); + }); + + it("refuses to load mixed valid and malformed persisted exclusions", async () => { + const registry = await loadRegistryWith({ + alpha: { + name: "alpha", + baselineExclusions: [ + { version: 1, agent: "hermes", key: "good", digest: "d".repeat(64) }, + { version: 1, agent: "hermes", key: "", digest: "e".repeat(64) }, + ], + }, + }); + + expect(() => registry.listSandboxes()).toThrow( + /invalid versioned baseline exclusion.*before rebuilding/i, + ); + }); +}); diff --git a/src/lib/state/registry-normalization.ts b/src/lib/state/registry-normalization.ts index 8f35c0cbb88..69e3e176667 100644 --- a/src/lib/state/registry-normalization.ts +++ b/src/lib/state/registry-normalization.ts @@ -2,7 +2,127 @@ // SPDX-License-Identifier: Apache-2.0 import { isObjectRecord } from "../core/json-types"; -import type { SandboxEntry } from "./registry"; +import type { BaselineExclusionEntry, BaselineExclusionTransition, SandboxEntry } from "./registry"; + +const BASELINE_TRANSITION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const BASELINE_TRANSITION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +const SHA256_DIGEST_PATTERN = /^[a-f0-9]{64}$/; + +function isCanonicalIsoTimestamp(value: string): boolean { + const parsed = new Date(value); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value; +} + +function normalizeBaselineExclusionEntry(item: unknown): BaselineExclusionEntry { + if (!isObjectRecord(item)) { + throw new Error( + "Sandbox registry contains a malformed baseline exclusion; repair the registry before rebuilding", + ); + } + const version = item.version; + const agent = typeof item.agent === "string" ? item.agent.trim() : ""; + const key = typeof item.key === "string" ? item.key.trim() : ""; + const digest = typeof item.digest === "string" ? item.digest.trim() : ""; + const acknowledgedAt = + typeof item.acknowledgedAt === "string" ? item.acknowledgedAt.trim() : item.acknowledgedAt; + if ( + version !== 1 || + !BASELINE_TRANSITION_KEY_PATTERN.test(agent) || + !BASELINE_TRANSITION_KEY_PATTERN.test(key) || + !SHA256_DIGEST_PATTERN.test(digest) || + (acknowledgedAt !== undefined && + (typeof acknowledgedAt !== "string" || !isCanonicalIsoTimestamp(acknowledgedAt))) + ) { + throw new Error( + "Sandbox registry contains an invalid versioned baseline exclusion; repair the registry before rebuilding", + ); + } + const entry: BaselineExclusionEntry = { version, agent, key, digest }; + if (typeof acknowledgedAt === "string") entry.acknowledgedAt = acknowledgedAt; + if (item.appliedAgentVersion === null) { + entry.appliedAgentVersion = null; + } else if (typeof item.appliedAgentVersion === "string") { + entry.appliedAgentVersion = item.appliedAgentVersion; + } else if (item.appliedAgentVersion !== undefined) { + throw new Error( + `Sandbox registry baseline exclusion '${key}' has an invalid agent version; repair the registry before rebuilding`, + ); + } + return entry; +} + +/** + * Coerce a persisted `baselineExclusions` value into well-formed entries. + * A legacy registry without the field yields `undefined`, while malformed + * exclusion state fails closed so rebuild cannot silently restore egress that + * the operator intended to remove. + */ +export function normalizeBaselineExclusions(value: unknown): BaselineExclusionEntry[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new Error( + "Sandbox registry baselineExclusions must be an array; repair the registry before rebuilding", + ); + } + const byKey = new Map(); + for (const item of value) { + const entry = normalizeBaselineExclusionEntry(item); + const { key } = entry; + byKey.set(key, entry); + } + return byKey.size > 0 ? [...byKey.values()] : undefined; +} + +/** Normalize the crash-recovery journal, rejecting partial or forged states. */ +export function normalizeBaselineExclusionTransition( + value: unknown, +): BaselineExclusionTransition | undefined { + if (value === undefined) return undefined; + if (!isObjectRecord(value)) { + throw new Error( + "Sandbox registry contains a malformed baseline exclusion transition; repair the registry before rebuilding", + ); + } + const id = typeof value.id === "string" ? value.id.trim() : ""; + const operation = value.operation; + const startedAt = typeof value.startedAt === "string" ? value.startedAt.trim() : ""; + if ( + !BASELINE_TRANSITION_ID_PATTERN.test(id) || + (operation !== "exclude" && operation !== "restore") || + !isCanonicalIsoTimestamp(startedAt) + ) { + throw new Error( + "Sandbox registry contains an incomplete baseline exclusion transition; repair the registry before rebuilding", + ); + } + const exclusion = normalizeBaselineExclusionEntry(value.exclusion); + if ( + !BASELINE_TRANSITION_KEY_PATTERN.test(exclusion.key) || + !SHA256_DIGEST_PATTERN.test(exclusion.digest) || + (exclusion.acknowledgedAt !== undefined && !isCanonicalIsoTimestamp(exclusion.acknowledgedAt)) + ) { + throw new Error( + "Sandbox registry contains an invalid baseline exclusion transition source; repair the registry before rebuilding", + ); + } + const targetLiveDigest = + value.targetLiveDigest === null + ? null + : typeof value.targetLiveDigest === "string" + ? value.targetLiveDigest.trim() + : ""; + if ( + (operation === "exclude" && targetLiveDigest !== null) || + (operation === "restore" && + (targetLiveDigest === null || !SHA256_DIGEST_PATTERN.test(targetLiveDigest))) + ) { + throw new Error( + `Sandbox registry baseline exclusion transition '${exclusion.key}' has an invalid live target; repair the registry before rebuilding`, + ); + } + return { id, operation, exclusion, targetLiveDigest, startedAt }; +} export function parseSandboxRegistryEntries(value: unknown): Array<[string, SandboxEntry]> { const sandboxes = isObjectRecord(value) ? value : {}; diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 46353f4579c..9ee071585d6 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { isErrnoException } from "../core/errno"; import { isObjectRecord } from "../core/json-types"; import { GATEWAY_PORT } from "../core/ports"; @@ -27,7 +28,12 @@ import { serializeSandboxMcpStateForDisk, } from "./registry-mcp"; import type { SandboxMessagingState } from "./registry-messaging"; -import { parseSandboxRegistryEntries, retainedDefaultSandbox } from "./registry-normalization"; +import { + normalizeBaselineExclusions, + normalizeBaselineExclusionTransition, + parseSandboxRegistryEntries, + retainedDefaultSandbox, +} from "./registry-normalization"; import * as reversibleRemoval from "./registry-reversible-removal"; import { nemoclawStateRoot } from "./state-root"; @@ -70,6 +76,37 @@ export interface CustomPolicyEntry { appliedAt?: string; } +export interface BaselineExclusionEntry { + /** Persistence schema version for this reviewed exclusion intent. */ + version: 1; + /** Agent baseline that supplied the reviewed entry. */ + agent: string; + /** Exact baseline network policy key excluded, e.g. "nous_research". */ + key: string; + /** Digest of the reviewed baseline entry content the approval was bound to. */ + digest: string; + /** When the exclusion was acknowledged. */ + acknowledgedAt?: string; + /** Agent build/version recorded when the exclusion was last applied. */ + appliedAgentVersion?: string | null; +} + +export type BaselineExclusionTransitionOperation = "exclude" | "restore"; + +/** + * Durable journal for the one cross-system baseline mutation that is in flight. + * `baselineExclusions` remains the last committed operator intent until this + * transaction is published after the live OpenShell mutation succeeds. + */ +export interface BaselineExclusionTransition { + id: string; + operation: BaselineExclusionTransitionOperation; + exclusion: BaselineExclusionEntry; + /** Exact live-entry digest that completes the transition; null means absent. */ + targetLiveDigest: string | null; + startedAt: string; +} + // Outcome of the last live sandbox GPU proof run during onboarding/recovery. // `status` separates a configured-but-unverified GPU from one whose CUDA // usability was actually proven (`verified`) or actively failed a live proof @@ -106,6 +143,10 @@ export interface SandboxEntry extends Partial { openshellVersion?: string | null; policies?: string[]; customPolicies?: CustomPolicyEntry[]; + /** Operator exclusions from the agent baseline policy, replayed on rebuild. */ + baselineExclusions?: BaselineExclusionEntry[]; + /** Crash-recoverable journal for an exclusion/restore live-policy mutation. */ + baselineExclusionTransition?: BaselineExclusionTransition; policyTier?: string | null; // True once the onboard policy step has fully completed and reconciled the // effective preset selection (set by the post-policy registry write). Absent @@ -428,11 +469,23 @@ function serializeRegistryForDisk(data: SandboxRegistry): SandboxRegistry { function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { const messaging = cloneSandboxMessagingState(entry.messaging); const mcp = normalizeSandboxMcpState(entry.mcp); - const { messaging: _messaging, mcp: _mcp, ...rest } = entry; + const baselineExclusions = normalizeBaselineExclusions(entry.baselineExclusions); + const baselineExclusionTransition = normalizeBaselineExclusionTransition( + entry.baselineExclusionTransition, + ); + const { + messaging: _messaging, + mcp: _mcp, + baselineExclusions: _baselineExclusions, + baselineExclusionTransition: _baselineExclusionTransition, + ...rest + } = entry; return { ...rest, ...(messaging ? { messaging } : {}), ...(mcp ? { mcp } : {}), + ...(baselineExclusions ? { baselineExclusions } : {}), + ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), }; } @@ -458,12 +511,24 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { }; const messaging = serializeSandboxMessagingStateForDisk(durable.messaging); const mcp = serializeSandboxMcpStateForDisk(durable.mcp); - const { messaging: _messaging, mcp: _mcp, ...rest } = durable; + const baselineExclusions = normalizeBaselineExclusions(durable.baselineExclusions); + const baselineExclusionTransition = normalizeBaselineExclusionTransition( + durable.baselineExclusionTransition, + ); + const { + messaging: _messaging, + mcp: _mcp, + baselineExclusions: _baselineExclusions, + baselineExclusionTransition: _baselineExclusionTransition, + ...rest + } = durable; return { ...rest, ...(rest.dashboardPort === 0 ? { dashboardPort: null } : {}), ...(messaging ? { messaging } : {}), ...(mcp ? { mcp } : {}), + ...(baselineExclusions ? { baselineExclusions } : {}), + ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), }; } @@ -506,6 +571,10 @@ export function registerSandbox(entry: SandboxEntry): void { openshellDriver: entry.openshellDriver || null, openshellVersion: entry.openshellVersion || null, policies: entry.policies || [], + baselineExclusions: normalizeBaselineExclusions(entry.baselineExclusions), + baselineExclusionTransition: normalizeBaselineExclusionTransition( + entry.baselineExclusionTransition, + ), policyTier: entry.policyTier || null, webSearchEnabled: typeof entry.webSearchEnabled === "boolean" ? entry.webSearchEnabled : undefined, @@ -755,6 +824,111 @@ export function removeCustomPolicyByName(name: string, presetName: string): bool }); } +/** Return the baseline exclusions recorded for a sandbox (never null). */ +export function getBaselineExclusions(name: string): BaselineExclusionEntry[] { + const data = load(); + return data.sandboxes[name]?.baselineExclusions ?? []; +} + +/** Upsert a baseline exclusion by key. Replaces any existing entry for the key. */ +export function addBaselineExclusion(name: string, entry: BaselineExclusionEntry): boolean { + return withLock(() => { + const data = load(); + const sandbox = data.sandboxes[name]; + if (!sandbox || sandbox.baselineExclusionTransition) return false; + const list = (sandbox.baselineExclusions ?? []).filter((e) => e.key !== entry.key); + list.push({ ...entry, acknowledgedAt: entry.acknowledgedAt ?? new Date().toISOString() }); + sandbox.baselineExclusions = list; + save(data); + return true; + }); +} + +/** Remove a baseline exclusion by key. Returns true if an entry was removed. */ +export function removeBaselineExclusion(name: string, key: string): boolean { + return withLock(() => { + const data = load(); + const sandbox = data.sandboxes[name]; + if (!sandbox || sandbox.baselineExclusionTransition) return false; + const list = sandbox.baselineExclusions ?? []; + const next = list.filter((e) => e.key !== key); + if (next.length === list.length) return false; + sandbox.baselineExclusions = next.length > 0 ? next : undefined; + save(data); + return true; + }); +} + +/** Return the one in-flight baseline policy transaction for a sandbox. */ +export function getBaselineExclusionTransition(name: string): BaselineExclusionTransition | null { + const data = load(); + return data.sandboxes[name]?.baselineExclusionTransition ?? null; +} + +/** + * Persist a new cross-system transaction before changing the live policy. + * Refuses to overwrite another pending transaction, even for the same key. + */ +export function beginBaselineExclusionTransition( + name: string, + transition: BaselineExclusionTransition, +): boolean { + return withLock(() => { + const data = load(); + const sandbox = data.sandboxes[name]; + if (!sandbox || sandbox.baselineExclusionTransition) return false; + sandbox.baselineExclusionTransition = normalizeBaselineExclusionTransition(transition); + save(data); + return true; + }); +} + +/** + * Publish the durable intent represented by a completed live mutation and + * clear its journal in the same registry-file replacement. + */ +export function commitBaselineExclusionTransition(name: string, id: string): boolean { + return withLock(() => { + const data = load(); + const sandbox = data.sandboxes[name]; + const transition = sandbox?.baselineExclusionTransition; + if (!sandbox || !transition || transition.id !== id) return false; + if (transition.operation === "exclude") { + const list = (sandbox.baselineExclusions ?? []).filter( + (entry) => entry.key !== transition.exclusion.key, + ); + list.push({ + ...transition.exclusion, + acknowledgedAt: transition.exclusion.acknowledgedAt ?? new Date().toISOString(), + }); + sandbox.baselineExclusions = list; + } else { + const list = sandbox.baselineExclusions ?? []; + const committed = list.find((entry) => entry.key === transition.exclusion.key); + // A restore may finalize only the exact durable exclusion it staged + // against. Preserve the journal if another writer changed the record. + if (!committed || !isDeepStrictEqual(committed, transition.exclusion)) return false; + const next = list.filter((entry) => entry.key !== transition.exclusion.key); + sandbox.baselineExclusions = next.length > 0 ? next : undefined; + } + sandbox.baselineExclusionTransition = undefined; + save(data); + return true; + }); +} + +/** Roll back only the exact pending transaction, preserving committed intent. */ +export function clearBaselineExclusionTransition(name: string, id: string): boolean { + return withLock(() => { + const data = load(); + const sandbox = data.sandboxes[name]; + if (!sandbox || sandbox.baselineExclusionTransition?.id !== id) return false; + sandbox.baselineExclusionTransition = undefined; + save(data); + return true; + }); +} + export function getDisabledChannels(name: string): string[] { return getRegistryDisabledChannels(name, { load }); } diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index 28a09777b39..e113d128864 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -361,7 +361,7 @@ describe("CLI dispatch", () => { argv: ["policy", "set"], entered: "policy set", command: "Run: openshell policy set --policy --wait ", - notes: ["nemoclaw policy-add "], + notes: ["nemoclaw policy add "], }, { argv: ["gateway", "stop"], diff --git a/test/e2e/live/snapshot-commands.test.ts b/test/e2e/live/snapshot-commands.test.ts index 81bc3a36ec7..f580b15c394 100644 --- a/test/e2e/live/snapshot-commands.test.ts +++ b/test/e2e/live/snapshot-commands.test.ts @@ -31,6 +31,8 @@ import { scanSnapshotCredentialLeaks } from "./snapshot-credential-scanner.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-snapshot"; validateSandboxName(SANDBOX_NAME); +const CLONE_SANDBOX_NAME = `${SANDBOX_NAME}-clone`; +validateSandboxName(CLONE_SANDBOX_NAME); const BACKUP_ROOT = path.join(os.homedir(), ".nemoclaw", "rebuild-backups"); const BACKUP_DIR = path.resolve(BACKUP_ROOT, SANDBOX_NAME); if (!BACKUP_DIR.startsWith(`${path.resolve(BACKUP_ROOT)}${path.sep}`)) { @@ -38,7 +40,8 @@ if (!BACKUP_DIR.startsWith(`${path.resolve(BACKUP_ROOT)}${path.sep}`)) { } const MARKER_FILE = "/sandbox/.openclaw/workspace/snapshot-marker.txt"; const SECOND_MARKER = "/sandbox/.openclaw/workspace/snapshot-marker-2.txt"; -const LIVE_TIMEOUT_MS = 30 * 60_000; +const BASELINE_EXCLUSION_KEY = "openclaw_docs"; +const LIVE_TIMEOUT_MS = 36 * 60_000; const INFERENCE_API_KEY = "nvapi-snapshot-commands-fixture-credential"; const INFERENCE_MODEL = "snapshot-commands-model"; @@ -57,20 +60,24 @@ async function bestEffortPreclean(run: () => Promise): Promise { async function precleanSnapshotSandbox( host: HostCliClient, sandbox: SandboxClient, + sandboxName: string, label: string, ): Promise { - await host.bestEffortCleanupSandbox(SANDBOX_NAME, { + await host.bestEffortCleanupSandbox(sandboxName, { artifactName: `${label}-nemoclaw-destroy`, env: commandEnv(), timeoutMs: 120_000, }); await bestEffortPreclean(() => - sandbox.openshell(["sandbox", "delete", SANDBOX_NAME], { + sandbox.openshell(["sandbox", "delete", sandboxName], { artifactName: `${label}-openshell-sandbox-delete`, env: commandEnv(), timeoutMs: 60_000, }), ); +} + +async function precleanSnapshotGateway(sandbox: SandboxClient, label: string): Promise { await bestEffortPreclean(() => sandbox.openshell(["gateway", "destroy", "-g", "nemoclaw"], { artifactName: `${label}-openshell-gateway-destroy`, @@ -82,11 +89,12 @@ async function precleanSnapshotSandbox( async function expectSandboxFileContent( sandbox: SandboxClient, + sandboxName: string, filePath: string, expected: string, artifactName: string, ): Promise { - const result = await sandbox.exec(SANDBOX_NAME, ["cat", filePath], { + const result = await sandbox.exec(sandboxName, ["cat", filePath], { artifactName, env: commandEnv(), timeoutMs: 30_000, @@ -95,6 +103,41 @@ async function expectSandboxFileContent( expect(result.stdout.trim()).toBe(expected); } +async function expectBaselineExclusionAgreement( + host: HostCliClient, + sandbox: SandboxClient, + sandboxName: string, + artifactPrefix: string, +): Promise { + const status = await host.command("nemoclaw", [sandboxName, "status", "--json"], { + artifactName: `${artifactPrefix}-nemoclaw-status-json`, + env: commandEnv(), + timeoutMs: 60_000, + }); + expect(status.exitCode, resultText(status)).toBe(0); + const statusJson = JSON.parse(status.stdout) as { baselineExclusions: string[] }; + expect(statusJson.baselineExclusions).toContain(BASELINE_EXCLUSION_KEY); + + const policyList = await host.command("nemoclaw", [sandboxName, "policy", "list"], { + artifactName: `${artifactPrefix}-nemoclaw-policy-list`, + env: commandEnv(), + timeoutMs: 60_000, + }); + expect(policyList.exitCode, resultText(policyList)).toBe(0); + expect(resultText(policyList)).toMatch( + new RegExp(`^[ \\t]+- ${BASELINE_EXCLUSION_KEY} \\(active\\)`, "m"), + ); + + const livePolicy = await sandbox.openshell(["policy", "get", "--base", sandboxName], { + artifactName: `${artifactPrefix}-openshell-policy-get-base`, + env: commandEnv(), + timeoutMs: 60_000, + }); + expect(livePolicy.exitCode, resultText(livePolicy)).toBe(0); + expect(resultText(livePolicy)).toMatch(/^[ \t]+managed_inference:/m); + expect(resultText(livePolicy)).not.toMatch(new RegExp(`^[ \\t]+${BASELINE_EXCLUSION_KEY}:`, "m")); +} + function firstSnapshotTimestamp(listOutput: string): string { const match = listOutput.match(/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d+Z/); if (!match) @@ -139,6 +182,8 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- "onboard authenticates to a hermetic compatible inference endpoint", "snapshot create reports Snapshot v created", "snapshot list shows versioned snapshots and parseable timestamps", + "baseline exclusions remain active in registry and live policy across rebuild", + "snapshot restore --to carries baseline exclusions into clone registry and live policy", "latest snapshot restore recovers latest workspace state", "timestamp-targeted restore recovers the first snapshot state", "snapshot directory excludes credential-bearing env/json files", @@ -195,8 +240,22 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- env: commandEnv(), timeoutMs: 120_000, }); + cleanup.trackDisposable(`delete OpenShell sandbox ${CLONE_SANDBOX_NAME}`, () => + sandbox.cleanupSandbox(CLONE_SANDBOX_NAME, { + artifactName: "cleanup-clone-openshell-sandbox-delete", + env: commandEnv(), + timeoutMs: 60_000, + }), + ); + cleanup.trackSandbox(host, CLONE_SANDBOX_NAME, { + artifactName: "cleanup-clone-nemoclaw-destroy", + env: commandEnv(), + timeoutMs: 120_000, + }); - await precleanSnapshotSandbox(host, sandbox, "pre-cleanup"); + await precleanSnapshotSandbox(host, sandbox, CLONE_SANDBOX_NAME, "pre-cleanup-clone"); + await precleanSnapshotSandbox(host, sandbox, SANDBOX_NAME, "pre-cleanup"); + await precleanSnapshotGateway(sandbox, "pre-cleanup"); fs.rmSync(BACKUP_DIR, { recursive: true, force: true }); progress.phase("onboard the snapshot sandbox"); @@ -251,6 +310,26 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- expect(cliProbe.stdout).toContain("nemoclaw"); expect(cliProbe.stdout).toContain("openshell"); + const excludeBaseline = await host.command( + "nemoclaw", + [SANDBOX_NAME, "policy", "exclude", BASELINE_EXCLUSION_KEY, "--force"], + { + artifactName: "phase-2-policy-exclude-baseline", + env: commandEnv(), + timeoutMs: 60_000, + }, + ); + expect(excludeBaseline.exitCode, resultText(excludeBaseline)).toBe(0); + await expectBaselineExclusionAgreement(host, sandbox, SANDBOX_NAME, "phase-2-after-exclude"); + + const rebuild = await host.command("nemoclaw", [SANDBOX_NAME, "rebuild", "--yes"], { + artifactName: "phase-2-rebuild-with-baseline-exclusion", + env: commandEnv(), + timeoutMs: 15 * 60_000, + }); + expect(rebuild.exitCode, resultText(rebuild)).toBe(0); + await expectBaselineExclusionAgreement(host, sandbox, SANDBOX_NAME, "phase-2-after-rebuild"); + const markerContent = `SNAPSHOT_E2E_${Date.now()}`; const secondContent = `SNAPSHOT_E2E_SECOND_${Date.now()}`; @@ -268,7 +347,13 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- }, ); expect(writeMarker.exitCode, resultText(writeMarker)).toBe(0); - await expectSandboxFileContent(sandbox, MARKER_FILE, markerContent, "phase-2-read-marker"); + await expectSandboxFileContent( + sandbox, + SANDBOX_NAME, + MARKER_FILE, + markerContent, + "phase-2-read-marker", + ); progress.phase("create and list the first snapshot"); const firstCreate = await host.command("nemoclaw", [SANDBOX_NAME, "snapshot", "create"], { @@ -290,6 +375,37 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- const timestamp = firstSnapshotTimestamp(resultText(list)); await artifacts.writeJson("phase-4-first-snapshot.json", { timestamp }); + const cloneRestore = await host.command( + "nemoclaw", + [SANDBOX_NAME, "snapshot", "restore", timestamp, "--to", CLONE_SANDBOX_NAME, "--yes"], + { + artifactName: "phase-4-snapshot-restore-to-clone", + env: commandEnv(), + timeoutMs: 5 * 60_000, + }, + ); + expect(cloneRestore.exitCode, resultText(cloneRestore)).toBe(0); + expect(resultText(cloneRestore)).toContain("Restored"); + await expectSandboxFileContent( + sandbox, + CLONE_SANDBOX_NAME, + MARKER_FILE, + markerContent, + "phase-4-read-clone-marker", + ); + await expectBaselineExclusionAgreement( + host, + sandbox, + CLONE_SANDBOX_NAME, + "phase-4-clone-baseline-exclusion", + ); + const destroyClone = await host.command("nemoclaw", [CLONE_SANDBOX_NAME, "destroy", "--yes"], { + artifactName: "phase-4-destroy-clone", + env: commandEnv(), + timeoutMs: 120_000, + }); + expect(destroyClone.exitCode, resultText(destroyClone)).toBe(0); + progress.phase("create a second snapshot from changed workspace"); const modify = await sandbox.exec( SANDBOX_NAME, @@ -338,6 +454,7 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- expect(resultText(latestRestore)).toContain("Restored"); await expectSandboxFileContent( sandbox, + SANDBOX_NAME, SECOND_MARKER, secondContent, "phase-6-read-second-marker-after-latest-restore", @@ -366,6 +483,7 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- expect(resultText(targetedRestore)).toContain("Restored"); await expectSandboxFileContent( sandbox, + SANDBOX_NAME, MARKER_FILE, markerContent, "phase-7-read-first-marker-after-targeted-restore", @@ -503,6 +621,7 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- expect(resultText(restoreStoppedBackup)).toContain("Restored"); await expectSandboxFileContent( sandbox, + SANDBOX_NAME, MARKER_FILE, markerContent, "phase-10-read-marker-after-stopped-backup-restore", @@ -519,6 +638,8 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- id: "snapshot-commands", status: "passed", firstSnapshotTimestamp: timestamp, + baselineExclusionKey: BASELINE_EXCLUSION_KEY, + cloneSandboxName: CLONE_SANDBOX_NAME, stoppedBackupTimestamp, backupDir: BACKUP_DIR, }); diff --git a/test/headless-server-docs.test.ts b/test/headless-server-docs.test.ts index ccafa3b6f3b..7af6f45dcd6 100644 --- a/test/headless-server-docs.test.ts +++ b/test/headless-server-docs.test.ts @@ -63,7 +63,7 @@ describe("headless server deployment guide contracts", () => { it("keeps the supported noninteractive policy and skill commands copyable (#7180)", () => { expect(guide).toContain( - "$$nemoclaw headless-agent policy-add --from-file ./presets/internal-status.yaml --yes", + "$$nemoclaw headless-agent policy add --from-file ./presets/internal-status.yaml --yes", ); expect(guide).toContain("$$nemoclaw headless-agent skill install ./my-skill/"); }); @@ -121,7 +121,7 @@ describe("headless server deployment guide contracts", () => { expect(guide).toContain( "| Item | Same-container restart | Snapshot and restore | Rebuild or sandbox upgrade |", ); - expect(guide).toContain("| Custom preset YAML applied with `policy-add` |"); + expect(guide).toContain("| Custom preset YAML applied with `policy add` |"); expect(guide).toContain("| Arbitrary files outside manifest state |"); expect(guide).toContain("| Manually installed system or global packages |"); expect(guide).toContain("| Direct edits to generated profile, config, or environment files |"); diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index 9507b22bc3e..d1b92dd0f2f 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -77,6 +77,16 @@ export type RebuildFlowOverrides = { agentName?: string; sessionAgentName?: string | null; applyPreset?: (presetName: string) => boolean; + captureOpenshell?: ( + args: string[], + options?: Record, + ) => { + status: number | null; + output?: string; + stdout?: string; + stderr?: string; + error?: Error; + }; executeSandboxCommand?: () => { status: number; stdout: string; stderr: string } | null; checkAndRecoverSandboxProcesses?: () => { checked: boolean; @@ -162,6 +172,7 @@ export type RebuildFlowHarness = { restoreSandboxEntrySpy: MockInstance; restoreRegistryEntryIfMissingSpy: MockInstance; restoreSandboxStateSpy: MockInstance; + captureOpenshellSpy: MockInstance; runOpenshellSpy: MockInstance; messagingRebuildPlanSpy: MockInstance; prepareMcpBridgesForRebuildSpy: MockInstance; @@ -525,6 +536,19 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): failedFiles: [], })), ); + const captureOpenshellSpy = vi + .spyOn(openshellRuntime, "captureOpenshell") + .mockImplementation((args: unknown, options?: unknown) => { + const argv = Array.isArray(args) ? args.map(String) : []; + return overrides.captureOpenshell + ? overrides.captureOpenshell(argv, options as Record | undefined) + : { + status: 1, + output: "", + stdout: "", + stderr: "Not Found: sandbox not found", + }; + }); const runOpenshellSpy = vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation((args) => { const argv = args as string[]; if (argv.join(" ") === "sandbox get alpha") { @@ -726,6 +750,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): restoreSandboxEntrySpy, restoreRegistryEntryIfMissingSpy, restoreSandboxStateSpy, + captureOpenshellSpy, runOpenshellSpy, messagingRebuildPlanSpy, prepareMcpBridgesForRebuildSpy, diff --git a/test/helpers/rebuild-flow-lifecycle-cases.ts b/test/helpers/rebuild-flow-lifecycle-cases.ts index 709bfaafcf1..0a9540643a9 100644 --- a/test/helpers/rebuild-flow-lifecycle-cases.ts +++ b/test/helpers/rebuild-flow-lifecycle-cases.ts @@ -137,6 +137,150 @@ export function registerRebuildFlowLifecycleTests(): void { ); }); + it("keeps baseline exclusions durable through successful replacement onboarding (#7194)", async () => { + const harness = createRebuildFlowHarness({ + sandboxEntry: { + baselineExclusions: [ + { + version: 1, + agent: "openclaw", + key: "openclaw_docs", + digest: "baseline-digest", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: "2026.6.10", + }, + ], + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes", "--verbose"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith("alpha"); + expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).toHaveBeenCalledOnce(); + expect(harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( + "Preserving baseline-exclusion registry entry across sandbox recreation", + ); + expect(harness.restoreSandboxEntrySpy).not.toHaveBeenCalled(); + expect(harness.restoreSandboxEntryIfMissingSpy).not.toHaveBeenCalled(); + }); + + it("rejects a schema-invalid recorded-agent baseline before registry or live sandbox mutation (#7194)", async () => { + const harness = createRebuildFlowHarness({ + agentPolicyAdditionsContent: ` +version: 1 +network_policies: + unsafe_entry: + name: unsafe_entry + endpoints: + - host: api.example.test + port: 443 + access: full +`, + preflightWithProductionBaselineResolver: true, + sandboxEntry: { + agent: "hermes", + baselineExclusions: [ + { + version: 1, + agent: "hermes", + key: "nous_research", + digest: "baseline-digest", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + ], + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes", "--verbose"], { throwOnError: true }), + ).rejects.toThrow("Replacement onboarding preflight failed"); + + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( + "does not satisfy the shipped sandbox policy schema", + ); + expect(harness.registryUpdateSpy).not.toHaveBeenCalled(); + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.prepareMcpBridgesForRebuildSpy).not.toHaveBeenCalled(); + expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect( + harness.runOpenshellSpy.mock.calls.some( + ([args]) => Array.isArray(args) && args.join(" ") === "sandbox delete alpha", + ), + ).toBe(false); + }); + + it("keeps baseline-exclusion retry metadata when inner replacement creation fails (#7194)", async () => { + const harness = createRebuildFlowHarness({ + sandboxEntry: { + baselineExclusions: [ + { + version: 1, + agent: "openclaw", + key: "openclaw_docs", + digest: "baseline-digest", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + appliedAgentVersion: "2026.6.10", + }, + ], + }, + onboard: () => { + throw new Error("injected replacement create failure"); + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes", "--verbose"], { throwOnError: true }), + ).rejects.toThrow("Recreate failed"); + + expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); + expect(harness.restoreSandboxEntrySpy).not.toHaveBeenCalled(); + expect(harness.restoreSandboxEntryIfMissingSpy).not.toHaveBeenCalled(); + expect(harness.errorSpy.mock.calls.map((call) => String(call[0])).join("\n")).toContain( + "Preserving baseline-exclusion registry entry across sandbox recreation", + ); + }); + + it("waits for post-delete sandbox absence before inner onboarding (#7194)", async () => { + const events: string[] = []; + let sandboxGetAttempts = 0; + const probeSequence = [ + { + event: "stale-live", + result: { status: 0, output: "Sandbox: alpha\nPhase: Ready" }, + }, + { + event: "absent", + result: { status: 1, output: "", stderr: "Not Found: sandbox not found" }, + }, + ]; + const harness = createRebuildFlowHarness({ + captureOpenshell: () => { + const probe = probeSequence[Math.min(sandboxGetAttempts, probeSequence.length - 1)]; + sandboxGetAttempts += 1; + events.push(probe.event); + return probe.result; + }, + onboard: () => { + events.push("onboard"); + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes", "--verbose"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(events).toEqual(["stale-live", "absent", "onboard"]); + expect( + harness.captureOpenshellSpy.mock.calls.filter( + ([args]) => Array.isArray(args) && args.join(" ") === "sandbox get alpha", + ), + ).toHaveLength(2); + }); + it("accepts the agent version cached by the confirmation probe before lock acquisition", async () => { const harness = createRebuildFlowHarness({ sandboxEntry: { agentVersion: null }, diff --git a/test/helpers/rebuild-flow-test-harness.ts b/test/helpers/rebuild-flow-test-harness.ts index 25e64166524..60fcf420cd2 100644 --- a/test/helpers/rebuild-flow-test-harness.ts +++ b/test/helpers/rebuild-flow-test-harness.ts @@ -68,10 +68,18 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const session = createRebuildFlowSession(onboardSession.MACHINE_SNAPSHOT_VERSION); const rebuildShieldsWindow = { relocked: false, wasLocked: false }; + let policyAdditionsPath: string | null = null; + if (typeof overrides.agentPolicyAdditionsContent === "string") { + const policyDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-agent-policy-")); + harnessTempDirs.push(policyDir); + policyAdditionsPath = path.join(policyDir, "policy-additions.yaml"); + fs.writeFileSync(policyAdditionsPath, overrides.agentPolicyAdditionsContent); + } const agentDef = { name: typeof overrides.sandboxEntry?.agent === "string" ? overrides.sandboxEntry.agent : "openclaw", expectedVersion: "0.2.0", + policyAdditionsPath, }; vi.spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue").mockReturnValue(null); @@ -392,6 +400,14 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): } return overrides.runOpenshell ? overrides.runOpenshell(argv) : { status: 0, output: "" }; }); + const captureOpenshellSpy = vi + .spyOn(openshellRuntime, "captureOpenshell") + .mockImplementation((args: unknown, options?: unknown) => { + const argv = Array.isArray(args) ? args.map(String) : []; + return overrides.captureOpenshell + ? overrides.captureOpenshell(argv, options as Record | undefined) + : { status: 1, output: "", stderr: "Not Found: sandbox not found" }; + }); const defaultRemovalReceipt = { entry: preDeleteSandboxEntry, wasDefault: preDeleteDefaultSandbox === "alpha", @@ -430,8 +446,14 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const options = args[0] as RebuildRecreateOnboardOpts; await overrides.onboard?.(session, options); }); - vi.spyOn(rebuildOnboardDependencies, "preflightAuthoritativeRebuildTarget").mockResolvedValue( - undefined, + vi.spyOn(rebuildOnboardDependencies, "preflightAuthoritativeRebuildTarget").mockImplementation( + async (options: unknown) => { + const preflightOptions = (options ?? {}) as Record; + if (overrides.preflightWithProductionBaselineResolver) { + policies.resolveSandboxBaselinePolicy(String(preflightOptions.sandboxName ?? "")); + } + await overrides.preflightAuthoritativeRebuildTarget?.(preflightOptions); + }, ); const ensureValidatedBraveSearchCredentialSpy = vi .spyOn(rebuildOnboardDependencies, "ensureValidatedWebSearchCredential") @@ -549,6 +571,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): releaseOnboardLockSpy, relockSpy, restoreSandboxStateSpy, + captureOpenshellSpy, runOpenshellSpy, messagingRebuildPlanSpy, prepareMcpBridgesForAbsentSandboxRebuildSpy, diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index 4753e5c8f39..9dff69238e5 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -64,6 +64,9 @@ export type RebuildFlowOverrides = { }; restoreMcpBridgesAfterRebuild?: () => Promise; buildMessagingRebuildPlan?: () => Promise | unknown; + agentPolicyAdditionsContent?: string; + preflightWithProductionBaselineResolver?: boolean; + preflightAuthoritativeRebuildTarget?: (options: Record) => Promise | void; sandboxEntry?: Record; sandboxBaseImageLabelsOutput?: string; sessionSandboxName?: string; @@ -89,6 +92,16 @@ export type RebuildFlowOverrides = { stdout?: string; stderr?: string; }; + captureOpenshell?: ( + args: string[], + options?: Record, + ) => { + status: number | null; + output?: string; + stdout?: string; + stderr?: string; + error?: Error; + }; backupPolicyPresets?: string[]; ensureValidatedBraveSearchCredential?: () => Promise; ensureValidatedWebSearchCredential?: () => Promise; @@ -129,6 +142,7 @@ export type RebuildFlowHarness = { releaseOnboardLockSpy: MockInstance; relockSpy: MockInstance; restoreSandboxStateSpy: MockInstance; + captureOpenshellSpy: MockInstance; runOpenshellSpy: MockInstance; messagingRebuildPlanSpy: MockInstance; prepareMcpBridgesForAbsentSandboxRebuildSpy: MockInstance; diff --git a/test/onboard-mcp-observability-redirect.test.ts b/test/onboard-mcp-observability-redirect.test.ts index 0b25b8037f1..ba69906e85e 100644 --- a/test/onboard-mcp-observability-redirect.test.ts +++ b/test/onboard-mcp-observability-redirect.test.ts @@ -23,6 +23,9 @@ describe("onboard managed MCP recreation redirect", () => { const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); + const dcodePolicyPath = JSON.stringify( + path.join(repoRoot, "agents", "langchain-deepagents-code", "policy-additions.yaml"), + ); const mocksPath = JSON.stringify( path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), ); @@ -64,7 +67,7 @@ registry.getDefault = () => null; const { createSandbox } = require(${onboardPath}); createSandbox( null, "model", "provider", "openai-completions", "alpha", null, null, null, - { name: "langchain-deepagents-code" }, null, null, null, [], null, + { name: "langchain-deepagents-code", policyAdditionsPath: ${dcodePolicyPath} }, null, null, null, [], null, { recreate: true, toolDisclosure: "progressive", diff --git a/test/onboard-pre-destructive-intent.test.ts b/test/onboard-pre-destructive-intent.test.ts index 60e9730bcdb..351c1bd757b 100644 --- a/test/onboard-pre-destructive-intent.test.ts +++ b/test/onboard-pre-destructive-intent.test.ts @@ -65,7 +65,7 @@ const resolved = { policy: { basePolicyPath: "/unused/policy.yaml", activeMessagingChannels: [], - options: { directGpu: false, additionalPresets: [], policyTier: null }, + options: { directGpu: false, additionalPresets: [], policyTier: null, baselineExclusions: [] }, }, gpuCreateArgs: [], resourceCreateArgs: [], diff --git a/test/onboard-sandbox-build.test.ts b/test/onboard-sandbox-build.test.ts index 547f9393c68..f5cb4deb67a 100644 --- a/test/onboard-sandbox-build.test.ts +++ b/test/onboard-sandbox-build.test.ts @@ -225,6 +225,9 @@ const { createSandbox } = require(${onboardPath}); const scriptPath = path.join(tmpDir, "agent-base-skip.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + const hermesPolicyPath = JSON.stringify( + path.join(repoRoot, "agents", "hermes", "policy-additions.yaml"), + ); const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); const preflightPath = JSON.stringify( path.join(repoRoot, "src", "lib", "onboard", "preflight.ts"), @@ -359,7 +362,7 @@ const { createSandbox } = require(${onboardPath}); healthProbe: { url: "http://127.0.0.1:8642/health", port: 8642, timeout_seconds: 90 }, dashboard: { kind: "ui", label: "Dashboard", path: "/", healthPath: "/api/status", auth: "session" }, expectedVersion: "2026.4.23", - policyAdditionsPath: null, + policyAdditionsPath: ${hermesPolicyPath}, }; await createSandbox( null, diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 70039cc9d54..646727a777a 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -275,6 +275,7 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain( "The mcporter locked graph reported no findings across `138` dependencies", ); + expect(review).toContain("`@hono/node-server` to patched release `2.0.11`"); expect(review).toContain("GHSA-frvp-7c67-39w9"); expect(review).toContain("Hono finding remains in the reviewed OpenClaw graph"); expect(review).toContain("GHSA-v422-hmwv-36x6"); diff --git a/test/package-contract/cli/command-registry.test.ts b/test/package-contract/cli/command-registry.test.ts index 87e4a4cc6ca..787e929ba27 100644 --- a/test/package-contract/cli/command-registry.test.ts +++ b/test/package-contract/cli/command-registry.test.ts @@ -57,16 +57,16 @@ describe("command-registry", () => { describe("sandboxCommands()", () => { it("should return exactly 60 entries", () => { - // 52 visible + 8 hidden (shields×3 + config get/set/rotate-token + + // 54 visible + 8 hidden (shields×3 + config get/set/rotate-token + // inference get/set). - // 52 visible includes the sessions group (root + list + reset + delete + + // 54 visible includes the sessions group (root + list + reset + delete + // export), the agents quartet (add + apply + delete + list), the // singular `agent` passthrough that forwards to `openclaw agent`, the // download + upload host-side openshell wrappers, the stop + start - // container lifecycle pair (#6026), plus five MCP bridge display entries - // under the `mcp` parent and the gateway restart command under the - // `gateway` parent. - expect(sandboxCommands()).toHaveLength(60); + // container lifecycle pair (#6026), the policy baseline exclude + restore + // pair, plus five MCP bridge display entries under the `mcp` parent and + // the gateway restart command under the `gateway` parent. + expect(sandboxCommands()).toHaveLength(62); }); it("every entry has scope sandbox", () => { @@ -225,9 +225,9 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 35 unique action tokens including empty string", () => { + it("returns exactly 31 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(35); + expect(tokens).toHaveLength(31); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "agent", @@ -242,11 +242,7 @@ describe("command-registry", () => { "doctor", "inference", "logs", - "policy-add", - "policy-explain", - "policy-get", - "policy-remove", - "policy-list", + "policy", "hosts-add", "hosts-list", "hosts-remove", diff --git a/test/package-contract/cli/policy-prompt-eof.test.ts b/test/package-contract/cli/policy-prompt-eof.test.ts index d767d056138..d56e24e38f5 100644 --- a/test/package-contract/cli/policy-prompt-eof.test.ts +++ b/test/package-contract/cli/policy-prompt-eof.test.ts @@ -70,9 +70,17 @@ require(${CLI_PATH}); describe("policy preset prompt cancellation", () => { it.each([ - { command: "policy-add" as const, menu: "Available presets:" }, - { command: "policy-remove" as const, menu: "Applied presets:" }, - ])("$command exits non-zero when the picker prompt hits EOF (#7418)", ({ command, menu }) => { + { command: "policy-add" as const, menu: "Available presets:", usage: "policy add " }, + { + command: "policy-remove" as const, + menu: "Applied presets:", + usage: "policy remove ", + }, + ])("$command exits non-zero when the picker prompt hits EOF (#7418)", ({ + command, + menu, + usage, + }) => { const result = runPolicyCommandAtStdinEof(command); // The child exited on its own rather than being killed by the defensive @@ -84,7 +92,7 @@ describe("policy preset prompt cancellation", () => { // NEMOCLAW_NON_INTERACTIVE=1 guard exiting earlier. expect(result.stderr).toContain(menu); expect(result.stderr).toContain("No input available on stdin"); - expect(result.stderr).toContain(`${command} `); + expect(result.stderr).toContain(usage); expect(result.status).toBe(1); // Above the child's 30s cap, so any hang fails on the assertions above // rather than as a bare suite timeout. diff --git a/test/package-contract/cli/public-argv-translation.test.ts b/test/package-contract/cli/public-argv-translation.test.ts index 991c39c060f..ee21995b8db 100644 --- a/test/package-contract/cli/public-argv-translation.test.ts +++ b/test/package-contract/cli/public-argv-translation.test.ts @@ -88,15 +88,44 @@ describe("public route/display separation", () => { "sandbox:hosts:add", "sandbox:hosts:list", "sandbox:hosts:remove", - "sandbox:policy:add", - "sandbox:policy:explain", - "sandbox:policy:get", - "sandbox:policy:list", - "sandbox:policy:remove", ]); expect(sandboxRouteTokens("sandbox:gateway:token")).toEqual(["gateway-token"]); expect(sandboxRouteTokens("sandbox:config:rotate-token")).toEqual(["config", "rotate-token"]); }); + + it.each([ + { verb: "add", args: ["github", "--yes"] }, + { verb: "explain", args: ["--json"] }, + { verb: "get", args: ["--raw"] }, + { verb: "list", args: [] }, + { verb: "remove", args: ["github", "--yes"] }, + ])("routes canonical and legacy policy $verb spellings to the same command (#7178)", ({ + verb, + args, + }) => { + const commandId = `sandbox:policy:${verb}`; + const expectedArgs = ["alpha", ...args]; + expect(sandboxRouteTokens(commandId)).toEqual(["policy", verb]); + expectNative( + translatePublicSandboxArgv("alpha", "policy", [verb, ...args]), + commandId, + expectedArgs, + ); + expectNative( + translatePublicSandboxArgv("alpha", `policy-${verb}`, args), + commandId, + expectedArgs, + ); + }); + + it("routes new policy subcommands only through their canonical two-token spelling (#7178)", () => { + expectNative( + translatePublicSandboxArgv("alpha", "policy", ["exclude", "nous_research", "--force"]), + "sandbox:policy:exclude", + ["alpha", "nous_research", "--force"], + ); + expect(sandboxRouteTokens("sandbox:policy:restore")).toEqual(["policy", "restore"]); + }); }); describe("translatePublicGlobalArgv", () => { diff --git a/test/package-contract/openshell-policy-boundary.test.ts b/test/package-contract/openshell-policy-boundary.test.ts index 73eb17ff3aa..639f7508a71 100644 --- a/test/package-contract/openshell-policy-boundary.test.ts +++ b/test/package-contract/openshell-policy-boundary.test.ts @@ -1,9 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; +import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -178,6 +179,114 @@ describe("OpenShell policy boundary package contract", () => { ).toBe(false); }); + it("ships an out-of-tree runtime sandbox-policy schema validator", { timeout: 90_000 }, () => { + const productionDependencyTree = spawnSync( + "npm", + ["ls", "ajv", "--omit=dev", "--all", "--json"], + { cwd: repoRoot, encoding: "utf8" }, + ); + expect( + productionDependencyTree.status, + `${productionDependencyTree.stdout}${productionDependencyTree.stderr}`, + ).toBe(0); + const productionDependencies = JSON.parse(productionDependencyTree.stdout) as { + dependencies?: { ajv?: { version?: string } }; + }; + expect(productionDependencies.dependencies?.ajv?.version).toMatch(/^8\./u); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-package-")); + try { + const packed = spawnSync( + "npm", + ["pack", "--ignore-scripts", "--silent", "--pack-destination", tempDir], + { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, npm_config_cache: path.join(tempDir, "npm-cache") }, + }, + ); + expect(packed.status, `${packed.stdout}${packed.stderr}`).toBe(0); + const archives = fs.readdirSync(tempDir).filter((entry) => entry.endsWith(".tgz")); + expect(archives).toHaveLength(1); + const archivePath = path.join(tempDir, archives[0]!); + execFileSync("tar", ["-xzf", archivePath, "-C", tempDir]); + const installedRoot = path.join(tempDir, "package"); + expect(fs.existsSync(path.join(installedRoot, "schemas", "sandbox-policy.schema.json"))).toBe( + true, + ); + expect( + fs.existsSync( + path.join(installedRoot, "dist", "lib", "policy", "sandbox-policy-validation.js"), + ), + ).toBe(true); + const installedNodeModules = path.join(installedRoot, "node_modules"); + for (const dependency of [ + "ajv", + "fast-deep-equal", + "fast-uri", + "json-schema-traverse", + "require-from-string", + "yaml", + ]) { + fs.cpSync( + path.join(repoRoot, "node_modules", dependency), + path.join(installedNodeModules, dependency), + { recursive: true }, + ); + } + + const validatorPath = path.join( + installedRoot, + "dist", + "lib", + "policy", + "sandbox-policy-validation.js", + ); + const probe = spawnSync( + process.execPath, + [ + "-e", + ` +const { parseAndValidateSandboxPolicy } = require(process.argv[1]); +const valid = [ + "version: 1", + "network_policies:", + " safe:", + " name: safe", + " endpoints:", + " - host: api.example.test", + " port: 443", + " access: full", + " binaries:", + " - path: /usr/bin/node", +].join("\\n"); +if (parseAndValidateSandboxPolicy(valid).version !== 1) process.exit(2); +const sensitivePolicyKey = "OPENAI_API_KEY_SUPERSECRET_VALUE"; +try { + parseAndValidateSandboxPolicy( + "version: 1\\nnetwork_policies:\\n " + + sensitivePolicyKey + + ": {name: unsafe, endpoints: []}", + ); + process.exit(3); +} catch (error) { + const message = String(error.message); + if (!message.includes("shipped sandbox policy schema")) process.exit(4); + if (message.includes(sensitivePolicyKey) || message.length > 600) process.exit(5); +} +process.stdout.write("validated"); +`, + validatorPath, + ], + { cwd: installedRoot, encoding: "utf8" }, + ); + expect(probe.status, `${probe.stdout}${probe.stderr}`).toBe(0); + expect(probe.stdout).toBe("validated"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("locks the generated sandbox boundary to its reviewed direct dependency", () => { const boundaryPath = path.join( repoRoot, diff --git a/test/policies.test.ts b/test/policies.test.ts index 665559b5755..06dd8ff41fd 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -793,7 +793,7 @@ exit 1 const combined = errors.join("\n"); expect(combined).toContain("my-assistant"); expect(combined).toMatch(/could not be\s+recorded locally/); - expect(combined).toMatch(/policy-list or status/); + expect(combined).toMatch(/policy list or status/); } finally { errSpy.mockRestore(); logSpy.mockRestore(); diff --git a/test/policy-explain-cli.test.ts b/test/policy-explain-cli.test.ts index bcd010ea9b5..775e1ed0608 100644 --- a/test/policy-explain-cli.test.ts +++ b/test/policy-explain-cli.test.ts @@ -131,7 +131,15 @@ describe("nemoclaw policy-explain (E2E)", () => { tier: { name: string } | null; activePresets: Array<{ name: string; allowedHostCategories: string[] }>; knownUnappliedPresets: Array<{ name: string }>; - approvalPath: { inspect: string; add: string; remove: string; documentation: string }; + baselineExclusions: Array<{ key: string; status: string; supportImpact: string }>; + approvalPath: { + inspect: string; + add: string; + remove: string; + excludeBaseline: string; + restoreBaseline: string; + documentation: string; + }; supportBoundaries: Array<{ capability: string; owner: string }>; }; @@ -141,8 +149,11 @@ describe("nemoclaw policy-explain (E2E)", () => { expect(active).toBeDefined(); expect(active?.allowedHostCategories).toContain("api.github.com"); expect(parsed.knownUnappliedPresets.some((p) => p.name === "slack")).toBe(true); - expect(parsed.approvalPath.inspect).toBe("nemoclaw policy-explain-json policy-list"); - expect(parsed.approvalPath.add).toBe("nemoclaw policy-explain-json policy-add "); + expect(parsed.approvalPath.inspect).toBe("nemoclaw policy-explain-json policy list"); + expect(parsed.approvalPath.add).toBe("nemoclaw policy-explain-json policy add "); + expect(parsed.baselineExclusions).toEqual([]); + expect(parsed.approvalPath.excludeBaseline).toContain("policy exclude --dry-run"); + expect(parsed.approvalPath.restoreBaseline).toContain("policy restore "); expect( parsed.supportBoundaries.some((b) => b.capability === "host allowlist enforcement"), ).toBe(true); diff --git a/test/policy-preset-picker.test.ts b/test/policy-preset-picker.test.ts index 036e63d4887..ae006f79100 100644 --- a/test/policy-preset-picker.test.ts +++ b/test/policy-preset-picker.test.ts @@ -250,7 +250,7 @@ describe("policy preset pickers", () => { it("rejects selecting an already-applied preset", async () => { const result = await runSelectionPrompt("selectFromList", "1\n", { applied: ["npm"] }); - expect(result.stderr).toMatch(/already applied\.[\s\S]*policy-add npm'/); + expect(result.stderr).toMatch(/already applied\.[\s\S]*policy add npm'/); expect(result.selected).toBeNull(); }); diff --git a/test/policy-roundtrip-docs.test.ts b/test/policy-roundtrip-docs.test.ts index fbdcd2331b9..45e60b414ef 100644 --- a/test/policy-roundtrip-docs.test.ts +++ b/test/policy-roundtrip-docs.test.ts @@ -42,7 +42,7 @@ describe("policy round-trip documentation examples", () => { const text = readDoc(docPath); expect(text, docPath).toContain("OpenShell 0.0.72+"); expect(text, docPath).toMatch( - /\$\$nemoclaw (?:my-assistant|) policy-get > current-policy\.yaml/, + /\$\$nemoclaw (?:my-assistant|) policy get > current-policy\.yaml/, ); expect(text, docPath).toMatch( /openshell policy set --policy current-policy\.yaml --wait (?:my-assistant|)/, @@ -54,9 +54,9 @@ describe("policy round-trip documentation examples", () => { it("documents raw output as diagnostic-only", () => { const commands = readDoc("docs/reference/commands.mdx"); - expect(commands).toContain("### `$$nemoclaw policy-get`"); - expect(commands).toContain("$$nemoclaw my-assistant policy-get > current-policy.yaml"); - expect(commands).toContain("$$nemoclaw my-assistant policy-get --raw"); + expect(commands).toContain("### `$$nemoclaw policy get`"); + expect(commands).toContain("$$nemoclaw my-assistant policy get > current-policy.yaml"); + expect(commands).toContain("$$nemoclaw my-assistant policy get --raw"); expect(commands).toContain("Do not pass `--raw` output to `openshell policy set`"); }); }); diff --git a/test/pr-workflow-contract.test.ts b/test/pr-workflow-contract.test.ts index 705caeaaf30..7b8484a7b45 100644 --- a/test/pr-workflow-contract.test.ts +++ b/test/pr-workflow-contract.test.ts @@ -985,6 +985,7 @@ describe("pull request and main workflow contracts", () => { ["pull_request", prWorkflow], ["main", mainWorkflow], ] as const) { + expect(workflow.jobs["cli-test-shards"]["timeout-minutes"], workflowName).toBe(15); const checkoutStep = requiredWorkflowStep(workflow.jobs["cli-test-shards"], "Checkout"); const shardStep = requiredWorkflowStep( workflow.jobs["cli-test-shards"], diff --git a/test/rebuild-credential-preflight.test.ts b/test/rebuild-credential-preflight.test.ts index d67e4779310..aa499cd40c0 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -171,6 +171,14 @@ const requiredFeatures = "request-body-credential-rewrite websocket-credential-r if (a[0] === "-V" || a[0] === "--version") { process.stdout.write("openshell 0.0.85\\n"); process.exit(0); } if (a[0] === "sandbox" && a[1] === "list") { process.stdout.write("${sandboxName} Ready\\n"); process.exit(0); } if (a[0] === "sandbox" && a[1] === "ssh-config") { process.stdout.write("${sshConfig}\\n"); process.exit(0); } +if (a[0] === "sandbox" && a[1] === "get") { + if (fs.existsSync(${JSON.stringify(deleteMarker)})) { + process.stderr.write("Not Found: sandbox not found\\n"); + process.exit(1); + } + process.stdout.write("Sandbox: ${sandboxName}\\nPhase: Ready\\n"); + process.exit(0); +} if (a[0] === "sandbox" && a[1] === "delete") { fs.writeFileSync(${JSON.stringify(deleteMarker)}, "deleted\\n"); process.exit(${sandboxDeleteExitCode}); } if (a[0] === "sandbox" && a[1] === "get") { if (fs.existsSync(${JSON.stringify(deleteMarker)})) { process.stderr.write("sandbox ${sandboxName} not found\\n"); process.exit(1); } diff --git a/test/sandbox-status-json-stdout.test.ts b/test/sandbox-status-json-stdout.test.ts index 2e44cf6dcfb..766da3f23a8 100644 --- a/test/sandbox-status-json-stdout.test.ts +++ b/test/sandbox-status-json-stdout.test.ts @@ -55,6 +55,9 @@ describe("sandbox status --json keeps stdout clean during gateway recovery", () expect(report.name).toBe("ghost-sandbox"); expect(report.found).toBe(false); expect(report.gatewayState).toBe("gateway_unreachable_after_restart"); + expect(report.baselineExclusions).toEqual([]); + expect(report.baselineExclusionStates).toEqual([]); + expect(report.baselineExclusionTransition).toBeNull(); }); it("reports unknown runtime when a non-OpenClaw registry agent cannot be loaded", async () => { @@ -66,13 +69,56 @@ describe("sandbox status --json keeps stdout clean during gateway recovery", () provider: "nvidia-prod", model: "test-model", policies: [], + baselineExclusions: [ + { + version: 1, + agent: "missing-terminal-agent", + key: "nous_research", + digest: "a".repeat(64), + }, + ], openshellDriver: "native", }) as never, reconcile: async () => ({ state: "missing", output: "" }), + getBaselineExclusionRuntimeStatus: () => "live-policy-mismatch", }); expect(report.agent).toBe("missing-terminal-agent"); expect(report.agentRuntime).toBe("unknown"); expect(report.agentLoadError).toMatch(/missing-terminal-agent/); + expect(report.baselineExclusions).toEqual(["nous_research"]); + expect(report.baselineExclusionStates).toEqual([ + { key: "nous_research", status: "live-policy-mismatch" }, + ]); + }); + + it("reports a pending baseline policy transaction separately from committed exclusions", async () => { + const report = await getSandboxStatusReport("repairing-sandbox", { + getSandbox: () => + ({ + name: "repairing-sandbox", + policies: [], + baselineExclusions: [], + baselineExclusionTransition: { + id: "tx-1", + operation: "exclude", + exclusion: { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "a".repeat(64), + }, + targetLiveDigest: null, + startedAt: "2026-07-19T00:00:00.000Z", + }, + }) as never, + reconcile: async () => ({ state: "missing", output: "" }), + }); + + expect(report.baselineExclusions).toEqual([]); + expect(report.baselineExclusionTransition).toEqual({ + operation: "exclude", + key: "nous_research", + }); }); }); diff --git a/test/snapshot-gateway-guard.test.ts b/test/snapshot-gateway-guard.test.ts index 856c61fa731..5f8331c8c24 100644 --- a/test/snapshot-gateway-guard.test.ts +++ b/test/snapshot-gateway-guard.test.ts @@ -5,15 +5,23 @@ // the openshell-cluster gateway container is stopped, even when // `openshell sandbox list` lies and returns exit 0 with stale data. -import { execSync } from "node:child_process"; +import { type ChildProcess, execSync, spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { execTimeout } from "./helpers/timeouts"; const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); +const listenerProcesses: ChildProcess[] = []; +let nextFixturePort = 46000 + (process.pid % 10000); + +afterEach(() => { + for (const child of listenerProcesses.splice(0)) { + child.kill("SIGKILL"); + } +}); type CliRunResult = { code: number; out: string }; @@ -81,6 +89,30 @@ function writeSandboxRegistry( ); } +function startReachableForward(port: number): void { + const listener = + 'const net=require("node:net");' + + "const server=net.createServer(()=>{});" + + `server.listen(${String(port)},"127.0.0.1");`; + const child = spawn(process.execPath, ["-e", listener], { stdio: "ignore" }); + listenerProcesses.push(child); + expect(child.pid, `test forward listener failed to spawn for ${String(port)}`).toBeDefined(); + + const probe = + "const net=require('node:net');" + + `const s=net.createConnection({host:'127.0.0.1',port:${String(port)}});` + + "s.setTimeout(100);" + + "s.on('connect',()=>{s.destroy();process.exit(0)});" + + "s.on('error',()=>process.exit(1));" + + "s.on('timeout',()=>{s.destroy();process.exit(1)});"; + const deadline = Date.now() + 2000; + let ready = false; + while (Date.now() < deadline && !ready) { + ready = spawnSync(process.execPath, ["-e", probe], { stdio: "ignore" }).status === 0; + } + expect(ready, `test forward listener failed to bind port ${String(port)}`).toBe(true); +} + function makeStoppedGatewayEnv(prefix: string): Record { const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const localBin = path.join(home, "bin"); @@ -153,20 +185,31 @@ function makeVmRestoreToEnv( const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); + const dashboardPort = nextFixturePort++; writeSandboxRegistry(home, "alpha", { openshellDriver: "vm", + dashboardPort, ...entry, }); + startReachableForward(dashboardPort); const cloneReadyMarker = path.join(home, "clone-1-ready"); + const cloneRunningMarker = path.join(home, "clone-1-running"); + const gatewayLifecycleLog = path.join(home, "gateway-lifecycle.log"); writeExecutable(path.join(localBin, "openshell"), [ 'case "$1 $2" in', ' "gateway info") printf "Gateway Info\\n\\nGateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080/\\n"; exit 0 ;;', ' "sandbox get") printf "{\\"name\\":\\"%s\\"}\\n" "$3"; exit 0 ;;', ` "sandbox list") if [ -f ${JSON.stringify(cloneReadyMarker)} ]; then printf "NAME STATUS\\nalpha Ready\\nclone-1 Ready\\n"; else printf "NAME STATUS\\nalpha Ready\\n"; fi; exit 0 ;;`, - ' "sandbox exec") printf "NEMOCLAW_DCODE_PROBE=no-runtime\\n"; exit 0 ;;', + ' "sandbox exec")', + ' case "$*" in', + ' *"__NEMOCLAW_SANDBOX_EXEC_STARTED__"*) printf "__NEMOCLAW_SANDBOX_EXEC_STARTED__\\nRUNNING\\n"; exit 0 ;;', + " esac", + ' printf "NEMOCLAW_DCODE_PROBE=no-runtime\\n"; exit 0 ;;', ' "sandbox ssh-config") printf "Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"; exit 0 ;;', - ` "sandbox create") touch ${JSON.stringify(cloneReadyMarker)}; printf "created clone-1\\n"; exit 0 ;;`, + ` "sandbox create") touch ${JSON.stringify(cloneReadyMarker)} ${JSON.stringify(cloneRunningMarker)}; printf "created clone-1\\n"; exit 0 ;;`, + ` "forward list") printf "SANDBOX BIND PORT PID STATUS\\nclone-1 127.0.0.1 ${String(dashboardPort)} 4242 running\\n"; exit 0 ;;`, + ' "forward stop") exit 1 ;;', "esac", 'if [ "$1" = "status" ]; then exit 0; fi', "exit 0", @@ -184,19 +227,50 @@ function makeVmRestoreToEnv( "exit 0", ]); - // `docker exec` must never run: if the fast path regresses, - // resolveSrcPodImage falls into the kubectl-via-docker probe and this - // marker shows up in the captured output. + // Model the supervisor-mediated gateway lifecycle used after restore. Keep the + // kubectl-via-gateway probe rejected so an image-resolution regression + // remains distinguishable from the expected clone gateway restart. writeExecutable(path.join(localBin, "docker"), [ + `CLONE_RUNNING_MARKER=${JSON.stringify(cloneRunningMarker)}`, + `LIFECYCLE_LOG=${JSON.stringify(gatewayLifecycleLog)}`, + 'if [ "$1" = "ps" ]; then', + ' target=""; format=""; all_states=0', + " for arg do", + ' case "$arg" in', + ' label=openshell.ai/sandbox-name=*) target="${arg##*=}" ;;', + ' *".Names"*|*".ID"*) format="$arg" ;;', + " -a) all_states=1 ;;", + " esac", + " done", + ' [ "$target" = "clone-1" ] || exit 0', + ' if [ -f "$CLONE_RUNNING_MARKER" ]; then status="Up 1 minute"; else status="Exited (0) 1 second ago"; fi', + ' if [ -f "$CLONE_RUNNING_MARKER" ] || [ "$all_states" = "1" ]; then', + ' case "$format" in', + ' *".ID"*) printf "clone-container-id\\topenshell-clone-1\\n" ;;', + ' *".Status"*) printf "openshell-clone-1\\t%s\\n" "$status" ;;', + ' *".Names"*) printf "openshell-clone-1\\n" ;;', + " esac", + " fi", + " exit 0", + "fi", 'if [ "$1" = "exec" ]; then', - ' echo "kubectl-must-not-run"', - " exit 1", + ' case "$*" in', + ' *"/usr/local/bin/nemoclaw-gateway-control restart "*) printf "restart clone-1\\n" >> "$LIFECYCLE_LOG"; printf "GATEWAY_PID=123\\n"; exit 0 ;;', + ' *"/usr/local/bin/nemoclaw-gateway-control probe "*) printf "GATEWAY_PID=123\\n"; exit 0 ;;', + ' *"/usr/bin/id -u sandbox"*) printf "1000\\n"; exit 0 ;;', + ' *"/usr/bin/id -g sandbox"*) printf "1000\\n"; exit 0 ;;', + " esac", + " for arg do", + ' if [ "$arg" = "kubectl" ]; then echo "kubectl-must-not-run"; exit 1; fi', + " done", + " exit 0", "fi", "exit 0", ]); return { HOME: home, + NEMOCLAW_GATEWAY_RECOVERY_SETTLE_SECONDS: "0", PATH: `${localBin}:${process.env.PATH ?? ""}`, }; } @@ -236,10 +310,13 @@ describe("snapshot VM-driver gateway guard", () => { expect(seed.out).toContain("Snapshot v1 name=baseline created"); const r = runCli("alpha snapshot restore baseline --to clone-1", env); - expect(r.code).toBe(0); + expect(r.code, r.out).toBe(0); expect(r.out).not.toContain("could not resolve"); expect(r.out).not.toContain("kubectl-must-not-run"); expect(r.out).toContain("openshell/sandbox-from:fast-path-test"); + expect(fs.readFileSync(path.join(env.HOME, "gateway-lifecycle.log"), "utf8")).toBe( + "restart clone-1\n", + ); }, 15000); it("snapshot restore --to fails closed for VM-driver entries missing imageTag", () => { diff --git a/test/support/snapshot-policy-test-fixture.ts b/test/support/snapshot-policy-test-fixture.ts new file mode 100644 index 00000000000..5894874dc1b --- /dev/null +++ b/test/support/snapshot-policy-test-fixture.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function resolveTestAgentBaselinePolicy(agent: string) { + return { + agent, + policyPath: + agent === "openclaw" + ? "/repo/nemoclaw-blueprint/policies/openclaw-sandbox.yaml" + : `/repo/agents/${agent}/policy-additions.yaml`, + content: "version: 1\nnetwork_policies: {}\n", + }; +} diff --git a/test/support/status-flow-test-harness.ts b/test/support/status-flow-test-harness.ts index 36709898080..1346e9ee01d 100644 --- a/test/support/status-flow-test-harness.ts +++ b/test/support/status-flow-test-harness.ts @@ -12,6 +12,8 @@ import type { ServingProcessHealth, } from "../../src/lib/actions/sandbox/status-snapshot"; import type { ProviderHealthStatus } from "../../src/lib/inference/health"; +import type { BaselineExclusionRuntimeStatus } from "../../src/lib/policy/baseline-exclusion"; +import type { BaselineExclusionTransition } from "../../src/lib/state/registry"; type ShowSandboxStatus = typeof import("../../src/lib/actions/sandbox/status")["showSandboxStatus"]; @@ -60,6 +62,7 @@ export type StatusFlowHarnessOptions = { routeDrift?: SandboxStatusRouteDrift | null; inferenceHealth?: ProviderHealthStatus | null; servingProcessHealth?: ServingProcessHealth | null; + baselineExclusionStatus?: BaselineExclusionRuntimeStatus; lookup?: SandboxGatewayState; lookupState?: "present" | "missing"; preflight?: SandboxStatusPreflightResult; @@ -67,6 +70,8 @@ export type StatusFlowHarnessOptions = { agent?: string | null; agentVersion?: string | null; dcodeAutoApprovalMode?: "disabled" | "thread-opt-in"; + baselineExclusions?: Array<{ version: 1; agent: string; key: string; digest: string }>; + baselineExclusionTransition?: BaselineExclusionTransition; }; shieldsPosture?: { mode: "locked" | "mutable_default" | "mutable"; @@ -99,6 +104,7 @@ export function createStatusFlowHarness(options: StatusFlowHarnessOptions = {}): const resolve = requireDist("../../src/lib/adapters/openshell/resolve.js"); const agentRuntime = requireDist("../../src/lib/agent/runtime.js"); const nim = requireDist("../../src/lib/inference/nim.js"); + const policy = requireDist("../../src/lib/policy/index.js"); const sandboxVersion = requireDist("../../src/lib/sandbox/version.js"); const shields = requireDist("../../src/lib/shields/index.js"); const registry = requireDist("../../src/lib/state/registry.js"); @@ -203,6 +209,9 @@ export function createStatusFlowHarness(options: StatusFlowHarnessOptions = {}): container: null, }); vi.spyOn(nim, "shouldShowNimLine").mockReturnValue(true); + vi.spyOn(policy, "getBaselineExclusionRuntimeStatus").mockReturnValue( + options.baselineExclusionStatus ?? "excluded", + ); const checkAgentVersionSpy = vi.spyOn(sandboxVersion, "checkAgentVersion").mockReturnValue( options.versionCheck ?? { sandboxVersion: "0.1.0",