From 117c96a73eb65be58ee9dac116df4535cd7c4d9b Mon Sep 17 00:00:00 2001 From: Dongni-Yang Date: Wed, 22 Jul 2026 11:15:08 +0800 Subject: [PATCH 1/6] fix(policy): re-apply edited presets in policy-add and document tls skip passthrough Naming an already-applied preset in policy-add previously failed on the registry name alone, so edited preset files were silently ignored until policy-remove/policy-add. Compare the preset content against the live gateway policy instead: a match becomes a successful idempotent no-op, drift or a missing live entry re-applies through the normal preview/confirm path, and unverifiable states (unreadable preset or live policy, or a name owned by a custom preset) keep the conservative failure. Document the access: full + tls: skip raw CONNECT passthrough for upstreams that reset the proxy's re-originated TLS handshake, switch the preset recipes to apply by name (a --from-file preset saved into the catalog collides with its own built-in name), and align the policy-add reference with the new contract. Refs #7323 Signed-off-by: Dongni-Yang Co-Authored-By: Claude Fable 5 --- .../customize-network-policy.mdx | 52 +++- .../integration-policy-examples.mdx | 3 +- docs/reference/commands.mdx | 4 +- .../sandbox/policy-channel-add-drift.test.ts | 244 ++++++++++++++++++ src/lib/actions/sandbox/policy-channel.ts | 66 ++++- src/lib/policy/index.ts | 6 + test/policies.test.ts | 6 + 7 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 src/lib/actions/sandbox/policy-channel-add-drift.test.ts diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 87c9e59f153..42f0912a000 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -183,13 +183,13 @@ network_policies: - { path: /usr/local/bin/node } ``` -Save it under `nemoclaw-blueprint/policies/presets/my-mcp.yaml` and apply it to the running sandbox: +Save it as `nemoclaw-blueprint/policies/presets/my-mcp.yaml` (the filename without `.yaml` must match `preset.name`) and apply it to the running sandbox by name: ```bash -$$nemoclaw my-assistant policy-add --from-file nemoclaw-blueprint/policies/presets/my-mcp.yaml +$$nemoclaw my-assistant policy-add my-mcp ``` -NemoClaw prompts for confirmation because the preset targets a custom host that is not part of the vetted allowlist, then reports `Widening sandbox egress — adding: mcp.example.com`. +NemoClaw previews the effective egress scope that the preset would open, including `mcp.example.com`, and prompts for confirmation before applying. For a publicly routed host that passes the separate SSRF checks, re-invoke the MCP tool and confirm that the CONNECT tunnel to `mcp.example.com:443` succeeds. @@ -210,6 +210,52 @@ Refer to [Agent cannot reach a host-side HTTP service](../reference/troubleshoot The `network_policies` key can use underscores because that field feeds the policy schema rather than the preset filename. +### Custom Recipe: Raw TLS Passthrough with `tls: skip` + +OpenShell's egress proxy terminates TLS for allowed HTTPS endpoints and re-originates a new handshake toward the upstream so it can inspect the traffic, and it does this even when the endpoint declares `access: full`. +Some CDN-fronted upstreams — Cloudflare-fronted APIs are the common case — reset that re-originated handshake, so the connection fails even though the host is in the allowlist. +The symptom is a reset or abort during the TLS handshake against a host that `$$nemoclaw my-assistant policy-list` shows as allowed, for example `curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL` or `ECONNRESET` from a client library. +Protocols that need to negotiate directly with the origin inside TLS break the same way: the proxy negotiates h2 ALPN when it terminates TLS, which is why WhatsApp's HTTP/1.1-only Noise-over-WebSocket handshake cannot pass through it. + +Declare the endpoint as a raw L4 CONNECT tunnel with `access: full` and `tls: skip`. +The proxy then passes the encrypted bytes through unmodified, and the client inside the sandbox negotiates TLS directly with the origin: + +```yaml +preset: + name: cf-fronted-api + description: "Cloudflare-fronted API that resets re-originated TLS" +network_policies: + cf_fronted_api: + name: cf_fronted_api + endpoints: + - host: api.example.com + port: 443 + access: full + tls: skip + binaries: + - { path: /usr/local/bin/node } +``` + +Save the file as `nemoclaw-blueprint/policies/presets/cf-fronted-api.yaml` so it joins the preset catalog, then apply it to the running sandbox by name: + +```bash +$$nemoclaw my-assistant policy-add cf-fronted-api +``` + +The filename without `.yaml` must match `preset.name`. +The preset catalog lists the `preset.name` header, but `policy-add ` loads `presets/.yaml` by filename, so a preset saved under a mismatched filename appears in the catalog yet reports `Preset not found` and applies nothing. +If you edit the preset file later, re-run the same command: `policy-add` compares the preset with the live policy and re-applies it when they no longer match. + +The `whatsapp` channel preset (`src/lib/messaging/channels/whatsapp/policy/openclaw.yaml`) uses the same shape for `web.whatsapp.com`, whose Noise handshake does not survive TLS re-origination. +Use it as a reference for combining `tls: skip` tunnel endpoints with ordinary inspected `protocol: rest` endpoints in one preset. + + +`tls: skip` disables L7 inspection for that endpoint. +The proxy cannot see or filter the HTTP method, path, or body inside the tunnel, so `rules` cannot constrain what the agent sends once the tunnel is established. +Only the policy's declared host, port, and `binaries` scoping remains for that endpoint. +Scope `tls: skip` to the specific hosts that need it — never a broad wildcard — and keep the default inspected path for every endpoint that tolerates it. + + ### Export, Edit, and Set the Base Policy Use this path only when you cannot add a file under the NemoClaw source tree. diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index c67217f9ca5..8e48ece24a9 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -100,7 +100,8 @@ $$nemoclaw my-assistant policy-add outlook --dry-run $$nemoclaw my-assistant policy-add outlook --yes ``` -`policy-add` exits non-zero if the `outlook` preset is already applied. If you applied it in the lifecycle example above, run `$$nemoclaw my-assistant policy-remove outlook --yes` first (or skip this apply step). +Re-running `policy-add outlook` when the preset is already applied is a safe no-op: the command compares the preset with the live policy, exits zero when nothing changed, and re-applies the preset when its file changed since it was applied. +You do not need to remove the preset first; removing it only to re-add it needlessly drops the entries from the live policy in between. Then configure the email or calendar tool credentials through the integration you run in the sandbox. Keep OAuth client secrets and refresh tokens out of policy files. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index dc1fa1a7255..f5fc84fc73a 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1673,7 +1673,9 @@ $$nemoclaw my-assistant policy-add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. -If the preset name is unknown or already applied, the command exits non-zero with a clear error. +If the preset name is unknown, the command exits non-zero with a clear error. +Naming a preset that is already applied is drift-aware: when the preset content still matches the live policy, the command reports the no-op and exits zero; when the preset no longer matches the live policy (for example after the preset file was edited), the command re-applies it through the normal preview and confirmation flow. +If the preset content or the live policy cannot be read for that comparison, or the name belongs to a custom preset applied with `--from-file`, the command exits non-zero instead of guessing. 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. 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. diff --git a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts new file mode 100644 index 00000000000..9ca4ee0ff3e --- /dev/null +++ b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Drift-aware `policy-add ` contract (#7323): naming an + * already-applied preset must compare the preset content against the live + * gateway policy instead of failing on the registry name alone. Users who + * edit a preset file in place (for example to add `tls: skip` endpoints) + * previously had their change silently ignored until they ran policy-remove + * followed by policy-add. + * + * - live policy still matches the preset -> successful idempotent no-op + * - preset content drifted from the live policy -> re-apply (fresh-add path, + * including the dry-run preview and interactive confirmation) + * - preset recorded but absent from the live policy -> re-apply + * - name owned by a custom (--from-file) preset -> refuse; the built-in + * content is the wrong comparison baseline and re-applying it would + * clobber the custom policy + * - preset content or live policy unreadable -> conservative failure + * because drift cannot be verified + */ + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +import { CLI_NAME } from "../../cli/branding"; +import * as store from "../../credentials/store"; +import * as policies from "../../policy"; +import * as onboardSession from "../../state/onboard-session"; +import * as registry from "../../state/registry"; +import { addSandboxPolicy } from "./policy-channel"; +import * as policyContextRefresh from "./policy-context-refresh"; + +type PresetInfo = ReturnType[number]; + +class ExitError extends Error { + constructor(public readonly code: number | undefined) { + super(`process.exit(${code})`); + } +} + +const POLICY_PRESETS: PresetInfo[] = [ + { file: "pypi.yaml", name: "pypi", description: "Python Package Index access" }, +]; + +let logSpy: MockInstance; +let errSpy: MockInstance; +let promptSpy: MockInstance; +let applyPresetMock: MockInstance; +let gatewayStateMock: MockInstance; +let refreshSpy: MockInstance; + +async function captureExit(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + if (error instanceof ExitError) return error.code; + throw error; + } + throw new Error("Expected process.exit to be called"); +} + +beforeEach(() => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code); + }) as never); + + promptSpy = vi.spyOn(store, "prompt").mockResolvedValue("y"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "alpha", + agent: null, + policies: ["pypi"], + }); + vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); + + vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); + vi.spyOn(onboardSession, "updateSession").mockReturnValue( + undefined as unknown as onboardSession.Session, + ); + + vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS); + vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); + vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["pypi"]); + vi.spyOn(policies, "loadPresetForSandbox").mockImplementation( + (_sandboxName: unknown, name: unknown) => + `network_policies:\n ${String(name)}:\n host: ${String(name)}.example.com\n`, + ); + applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); + gatewayStateMock = vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("drift"); + vi.spyOn(policies, "getPresetEndpoints").mockReturnValue(["pypi.example.com"]); + vi.spyOn(policies, "getPresetValidationWarning").mockReturnValue(null); + + refreshSpy = vi + .spyOn(policyContextRefresh, "refreshSandboxPolicyContextFile") + .mockReturnValue({ outcome: "ok", written: true }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete process.env.NEMOCLAW_NON_INTERACTIVE; +}); + +describe("addSandboxPolicy drift-aware named re-add", () => { + it("re-applies a named preset whose content drifted from the live policy", async () => { + gatewayStateMock.mockReturnValue("drift"); + + await addSandboxPolicy("alpha", { preset: "pypi", yes: true }); + + expect(gatewayStateMock).toHaveBeenCalledWith( + "alpha", + expect.stringContaining("pypi.example.com"), + ); + expect(logSpy).toHaveBeenCalledWith(" Preset 'pypi' no longer matches the live policy."); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Effective egress scope that would replace the current preset policy", + ), + ); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi", { suppressDisclosure: true }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("alpha"); + }); + + it("treats a matching named re-add as a successful no-op instead of a failure", async () => { + gatewayStateMock.mockReturnValue("match"); + + await addSandboxPolicy("alpha", { preset: "pypi", yes: true }); + + expect(logSpy).toHaveBeenCalledWith( + " Preset 'pypi' is already applied and matches the live policy; nothing to do.", + ); + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("re-applies when the preset is recorded but its entries are absent from the live policy", async () => { + gatewayStateMock.mockReturnValue("absent"); + + await addSandboxPolicy("alpha", { preset: "pypi", yes: true }); + + expect(logSpy).toHaveBeenCalledWith( + " Preset 'pypi' is recorded as applied but missing from the live policy.", + ); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi", { suppressDisclosure: true }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it("previews a drift re-apply without mutating on --dry-run", async () => { + gatewayStateMock.mockReturnValue("drift"); + + await addSandboxPolicy("alpha", { preset: "pypi", yes: true, dryRun: true }); + + expect(gatewayStateMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(" Preset 'pypi' no longer matches the live policy."); + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("asks for confirmation before a drift re-apply and honors decline", async () => { + promptSpy.mockResolvedValue("n"); + gatewayStateMock.mockReturnValue("drift"); + + await addSandboxPolicy("alpha", { preset: "pypi" }); + + expect(promptSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(" Preset 'pypi' no longer matches the live policy."); + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("re-applies drift without prompting when NEMOCLAW_NON_INTERACTIVE=1", async () => { + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + gatewayStateMock.mockReturnValue("drift"); + + await addSandboxPolicy("alpha", { preset: "pypi" }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi", { suppressDisclosure: true }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it("refuses a built-in re-add when the name is owned by a custom preset", async () => { + vi.spyOn(registry, "getCustomPolicies").mockReturnValue([ + { name: "pypi", content: "network_policies:\n pypi:\n host: custom.example.com\n" }, + ]); + + await expect( + captureExit(() => addSandboxPolicy("alpha", { preset: "pypi", yes: true })), + ).resolves.toBe(1); + + expect(errSpy).toHaveBeenCalledWith( + " 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.`, + ); + expect(gatewayStateMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("fails without an already-applied claim when the preset content cannot be read", async () => { + vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue(null); + + await expect( + captureExit(() => addSandboxPolicy("alpha", { preset: "pypi", yes: true })), + ).resolves.toBe(1); + + expect(errSpy).toHaveBeenCalledWith(" Could not read the content of preset 'pypi'."); + expect(errSpy).not.toHaveBeenCalledWith(expect.stringContaining("already applied")); + expect(gatewayStateMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("keeps the already-applied failure when the live policy cannot be read", async () => { + gatewayStateMock.mockReturnValue(null); + + await expect( + captureExit(() => addSandboxPolicy("alpha", { preset: "pypi", yes: true })), + ).resolves.toBe(1); + + expect(errSpy).toHaveBeenCalledWith(" Preset 'pypi' is already applied."); + 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(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not classify drift for a preset that is not applied yet", async () => { + vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + + await addSandboxPolicy("alpha", { preset: "pypi", yes: true }); + + expect(gatewayStateMock).not.toHaveBeenCalled(); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi", { suppressDisclosure: true }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index c35e7b885be..254e6dfda0a 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -86,7 +86,11 @@ const YW = useColor ? "\x1b[1;33m" : ""; * `--yes`/`-y`/`--force` (or `NEMOCLAW_NON_INTERACTIVE=1`) skips the * confirmation prompt. `--from-dir` applies non-hidden files in lexicographic * order and aborts at the first failure (already-applied presets are not - * rolled back). + * rolled back). Naming an already-applied preset compares the preset content + * against the live policy: a match is a successful no-op, while drift (an + * edited preset file) re-applies the preset through the normal path (#7323). + * Names owned by a custom (--from-file) preset are refused; re-apply those + * with `--from-file`. */ export async function addSandboxPolicy( sandboxName: string, @@ -149,6 +153,7 @@ async function addSandboxPolicyUnlocked( const applied = policies.getAppliedPresets(sandboxName); let answer = null; + let reapplyState: "drift" | "absent" | null = null; if (presetArg) { const normalized = presetArg.trim().toLowerCase(); const preset = allPresets.find((item: { name: string }) => item.name === normalized); @@ -160,8 +165,55 @@ async function addSandboxPolicyUnlocked( process.exit(1); } if (applied.includes(preset.name)) { - console.error(` Preset '${preset.name}' is already applied.`); - process.exit(1); + // #7323: the registry name alone must not block a re-add. Users edit + // preset files in place (for example to add `tls: skip` endpoints), so + // compare the preset content against the live gateway policy and fall + // through to a normal re-apply when it drifted. + const customNames = registry + .getCustomPolicies(sandboxName) + .map((entry: { name: string }) => entry.name); + if (customNames.includes(preset.name)) { + // A custom preset owns this name, so the built-in content is the + // wrong comparison baseline; re-applying it would clobber the custom + // 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.`, + ); + process.exit(1); + } + const appliedContent = policies.loadPresetForSandbox(sandboxName, preset.name); + if (!appliedContent) { + console.error(` Could not read the content of preset '${preset.name}'.`); + process.exit(1); + } + 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 + // 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.`, + ); + return; + } + if (appliedState === null) { + // Live policy unreadable: drift is unverifiable, so refuse rather + // than guess. + console.error(` Preset '${preset.name}' is already applied.`); + console.error( + " Could not read the live sandbox policy to compare (is the sandbox gateway running?).", + ); + process.exit(1); + } + // State-only notice: the downstream flow reports the dry-run, + // confirmation, and apply outcomes. + reapplyState = appliedState; + console.log( + appliedState === "drift" + ? ` Preset '${preset.name}' no longer matches the live policy.` + : ` Preset '${preset.name}' is recorded as applied but missing from the live policy.`, + ); } answer = preset.name; } else { @@ -177,7 +229,13 @@ async function addSandboxPolicyUnlocked( const presetContent = policies.loadPresetForSandbox(sandboxName, answer); if (!presetContent) return; - policies.logPresetScope(presetContent); + if (reapplyState) { + // A re-add replaces the recorded entries, so use the state-aware heading + // instead of the fresh-add "would be opened" preview. + policies.logPresetScopeForState(answer, presetContent, reapplyState); + } else { + policies.logPresetScope(presetContent); + } const presetWarning = policies.getPresetValidationWarning(answer); if (presetWarning) { diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 1374e9413e7..10ebbb60d1e 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -13,6 +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 { CLI_NAME } from "../cli/branding"; import { getMessagingPolicyKeyAliases, getMessagingPolicyPresetValidationWarnings, @@ -1541,7 +1542,12 @@ function selectFromList( return; } 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). 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`, + ); resolve(null); return; } diff --git a/test/policies.test.ts b/test/policies.test.ts index 09724ba7fb9..df047631907 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -16,6 +16,9 @@ const REPO_ROOT = path.join(import.meta.dirname, ".."); const policies = requireForTest( path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), ) as typeof import("../src/lib/policy"); +const { CLI_NAME } = requireForTest( + path.join(REPO_ROOT, "src", "lib", "cli", "branding.ts"), +) as typeof import("../src/lib/cli/branding"); const resolveOpenshellModule = requireForTest( path.join(REPO_ROOT, "src", "lib", "adapters", "openshell", "resolve.ts"), ) as { resolveOpenshell: (...args: unknown[]) => string | null }; @@ -1191,6 +1194,9 @@ exit 1 const result = await runSelectionPrompt("selectFromList", "1\n", { applied: ["npm"] }); expect(result.stderr).toContain("Preset 'npm' is already applied."); + expect(result.stderr).toContain( + `If its preset file changed, run '${CLI_NAME} policy-add npm' to re-apply it.`, + ); expect(result.selected).toBeNull(); }); From ee791cbbf903db95a2e182194ce31a1a0dff4673 Mon Sep 17 00:00:00 2001 From: Dongni-Yang Date: Wed, 22 Jul 2026 11:38:01 +0800 Subject: [PATCH 2/6] test(policy): keep policies.test.ts within its size budget The codebase-growth guardrail caps test/policies.test.ts at 1530 lines; fold the picker-hint pin into the existing already-applied assertion as a single regex instead of adding an import and a second assertion. Refs #7323 Signed-off-by: Dongni-Yang Co-Authored-By: Claude Fable 5 --- test/policies.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/policies.test.ts b/test/policies.test.ts index df047631907..fea960c35d6 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -16,9 +16,6 @@ const REPO_ROOT = path.join(import.meta.dirname, ".."); const policies = requireForTest( path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), ) as typeof import("../src/lib/policy"); -const { CLI_NAME } = requireForTest( - path.join(REPO_ROOT, "src", "lib", "cli", "branding.ts"), -) as typeof import("../src/lib/cli/branding"); const resolveOpenshellModule = requireForTest( path.join(REPO_ROOT, "src", "lib", "adapters", "openshell", "resolve.ts"), ) as { resolveOpenshell: (...args: unknown[]) => string | null }; @@ -1193,10 +1190,7 @@ exit 1 it("rejects selecting an already-applied preset", async () => { const result = await runSelectionPrompt("selectFromList", "1\n", { applied: ["npm"] }); - expect(result.stderr).toContain("Preset 'npm' is already applied."); - expect(result.stderr).toContain( - `If its preset file changed, run '${CLI_NAME} policy-add npm' to re-apply it.`, - ); + expect(result.stderr).toMatch(/already applied\.[\s\S]*policy-add npm'/); expect(result.selected).toBeNull(); }); From 5adddbb7e39173c341afd587aef0adcc69a58687 Mon Sep 17 00:00:00 2001 From: Dongni-Yang Date: Wed, 22 Jul 2026 11:42:06 +0800 Subject: [PATCH 3/6] test(policy): keep drift-suite test bodies free of if statements The growth guardrail rejects new if statements in changed test files; rewrite the captureExit helper assertion-style. Refs #7323 Signed-off-by: Dongni-Yang Co-Authored-By: Claude Fable 5 --- .../sandbox/policy-channel-add-drift.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 9ca4ee0ff3e..6f157aacf26 100644 --- a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts +++ b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts @@ -50,13 +50,12 @@ let gatewayStateMock: MockInstance; let refreshSpy: MockInstance; async function captureExit(action: () => Promise): Promise { - try { - await action(); - } catch (error) { - if (error instanceof ExitError) return error.code; - throw error; - } - throw new Error("Expected process.exit to be called"); + const outcome: unknown = await action().then( + () => new Error("Expected process.exit to be called"), + (error: unknown) => error, + ); + expect(outcome).toBeInstanceOf(ExitError); + return (outcome as ExitError).code; } beforeEach(() => { From 2c47a2bc1e02ab5ae9bb02b50699ee6e3a9410eb Mon Sep 17 00:00:00 2001 From: Dongni-Yang Date: Wed, 22 Jul 2026 12:12:57 +0800 Subject: [PATCH 4/6] docs(policy): keep tls skip recipe out of the MCP round-trip test slice test/policy-roundtrip-docs.test.ts slices customize-network-policy.mdx from the URL-Based MCP Server heading to the Export heading and pins exactly one binaries path entry in that slice; place the new raw TLS passthrough recipe above the MCP recipe so the slice stays scoped. Refs #7323 Signed-off-by: Dongni-Yang Co-Authored-By: Claude Fable 5 --- .../customize-network-policy.mdx | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 42f0912a000..91dc4e2fd22 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -148,6 +148,52 @@ Provider-composed `_provider_*` entries are excluded because OpenShell reserves Existing presets and the baseline remain in place. The preset file under `presets/` also persists across sandbox recreations. +### Custom Recipe: Raw TLS Passthrough with `tls: skip` + +OpenShell's egress proxy terminates TLS for allowed HTTPS endpoints and re-originates a new handshake toward the upstream so it can inspect the traffic, and it does this even when the endpoint declares `access: full`. +Some CDN-fronted upstreams — Cloudflare-fronted APIs are the common case — reset that re-originated handshake, so the connection fails even though the host is in the allowlist. +The symptom is a reset or abort during the TLS handshake against a host that `$$nemoclaw my-assistant policy-list` shows as allowed, for example `curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL` or `ECONNRESET` from a client library. +Protocols that need to negotiate directly with the origin inside TLS break the same way: the proxy negotiates h2 ALPN when it terminates TLS, which is why WhatsApp's HTTP/1.1-only Noise-over-WebSocket handshake cannot pass through it. + +Declare the endpoint as a raw L4 CONNECT tunnel with `access: full` and `tls: skip`. +The proxy then passes the encrypted bytes through unmodified, and the client inside the sandbox negotiates TLS directly with the origin: + +```yaml +preset: + name: cf-fronted-api + description: "Cloudflare-fronted API that resets re-originated TLS" +network_policies: + cf_fronted_api: + name: cf_fronted_api + endpoints: + - host: api.example.com + port: 443 + access: full + tls: skip + binaries: + - { path: /usr/local/bin/node } +``` + +Save the file as `nemoclaw-blueprint/policies/presets/cf-fronted-api.yaml` so it joins the preset catalog, then apply it to the running sandbox by name: + +```bash +$$nemoclaw my-assistant policy-add cf-fronted-api +``` + +The filename without `.yaml` must match `preset.name`. +The preset catalog lists the `preset.name` header, but `policy-add ` loads `presets/.yaml` by filename, so a preset saved under a mismatched filename appears in the catalog yet reports `Preset not found` and applies nothing. +If you edit the preset file later, re-run the same command: `policy-add` compares the preset with the live policy and re-applies it when they no longer match. + +The `whatsapp` channel preset (`src/lib/messaging/channels/whatsapp/policy/openclaw.yaml`) uses the same shape for `web.whatsapp.com`, whose Noise handshake does not survive TLS re-origination. +Use it as a reference for combining `tls: skip` tunnel endpoints with ordinary inspected `protocol: rest` endpoints in one preset. + + +`tls: skip` disables L7 inspection for that endpoint. +The proxy cannot see or filter the HTTP method, path, or body inside the tunnel, so `rules` cannot constrain what the agent sends once the tunnel is established. +Only the policy's declared host, port, and `binaries` scoping remains for that endpoint. +Scope `tls: skip` to the specific hosts that need it — never a broad wildcard — and keep the default inspected path for every endpoint that tolerates it. + + ### Custom Recipe: URL-Based MCP Server Adding a Streamable HTTP MCP server URL to OpenClaw (for example `https://mcp.example.com/mcp`) can result in the sandbox proxy denying the CONNECT tunnel with `HTTP 403 Forbidden` because the target host is not in the default allowlist. @@ -210,52 +256,6 @@ Refer to [Agent cannot reach a host-side HTTP service](../reference/troubleshoot The `network_policies` key can use underscores because that field feeds the policy schema rather than the preset filename. -### Custom Recipe: Raw TLS Passthrough with `tls: skip` - -OpenShell's egress proxy terminates TLS for allowed HTTPS endpoints and re-originates a new handshake toward the upstream so it can inspect the traffic, and it does this even when the endpoint declares `access: full`. -Some CDN-fronted upstreams — Cloudflare-fronted APIs are the common case — reset that re-originated handshake, so the connection fails even though the host is in the allowlist. -The symptom is a reset or abort during the TLS handshake against a host that `$$nemoclaw my-assistant policy-list` shows as allowed, for example `curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL` or `ECONNRESET` from a client library. -Protocols that need to negotiate directly with the origin inside TLS break the same way: the proxy negotiates h2 ALPN when it terminates TLS, which is why WhatsApp's HTTP/1.1-only Noise-over-WebSocket handshake cannot pass through it. - -Declare the endpoint as a raw L4 CONNECT tunnel with `access: full` and `tls: skip`. -The proxy then passes the encrypted bytes through unmodified, and the client inside the sandbox negotiates TLS directly with the origin: - -```yaml -preset: - name: cf-fronted-api - description: "Cloudflare-fronted API that resets re-originated TLS" -network_policies: - cf_fronted_api: - name: cf_fronted_api - endpoints: - - host: api.example.com - port: 443 - access: full - tls: skip - binaries: - - { path: /usr/local/bin/node } -``` - -Save the file as `nemoclaw-blueprint/policies/presets/cf-fronted-api.yaml` so it joins the preset catalog, then apply it to the running sandbox by name: - -```bash -$$nemoclaw my-assistant policy-add cf-fronted-api -``` - -The filename without `.yaml` must match `preset.name`. -The preset catalog lists the `preset.name` header, but `policy-add ` loads `presets/.yaml` by filename, so a preset saved under a mismatched filename appears in the catalog yet reports `Preset not found` and applies nothing. -If you edit the preset file later, re-run the same command: `policy-add` compares the preset with the live policy and re-applies it when they no longer match. - -The `whatsapp` channel preset (`src/lib/messaging/channels/whatsapp/policy/openclaw.yaml`) uses the same shape for `web.whatsapp.com`, whose Noise handshake does not survive TLS re-origination. -Use it as a reference for combining `tls: skip` tunnel endpoints with ordinary inspected `protocol: rest` endpoints in one preset. - - -`tls: skip` disables L7 inspection for that endpoint. -The proxy cannot see or filter the HTTP method, path, or body inside the tunnel, so `rules` cannot constrain what the agent sends once the tunnel is established. -Only the policy's declared host, port, and `binaries` scoping remains for that endpoint. -Scope `tls: skip` to the specific hosts that need it — never a broad wildcard — and keep the default inspected path for every endpoint that tolerates it. - - ### Export, Edit, and Set the Base Policy Use this path only when you cannot add a file under the NemoClaw source tree. From 04ec0c319ed3bbcd791f347a1ab8b7f9dcaeaab0 Mon Sep 17 00:00:00 2001 From: Dongni-Yang Date: Wed, 22 Jul 2026 12:40:56 +0800 Subject: [PATCH 5/6] docs(policy): describe drift re-apply as any live-policy mismatch CodeRabbit: the previous wording implied re-apply only follows preset file edits; drift also covers live-policy overrides and removed entries. Refs #7323 Signed-off-by: Dongni-Yang Co-Authored-By: Claude Fable 5 --- docs/network-policy/integration-policy-examples.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index 8e48ece24a9..5d31c86a322 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -100,7 +100,7 @@ $$nemoclaw my-assistant policy-add outlook --dry-run $$nemoclaw my-assistant policy-add outlook --yes ``` -Re-running `policy-add outlook` when the preset is already applied is a safe no-op: the command compares the preset with the live policy, exits zero when nothing changed, and re-applies the preset when its file changed since it was applied. +Re-running `policy-add outlook` when the preset is already applied is a safe no-op: the command compares the preset with the live policy, exits zero when they match, and re-applies the preset whenever they no longer match, including after the preset file changes. You do not need to remove the preset first; removing it only to re-add it needlessly drops the entries from the live policy in between. Then configure the email or calendar tool credentials through the integration you run in the sandbox. From a75b3a026bbc4c36130f9019a75ff29ccca56f33 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 23 Jul 2026 15:47:15 -0700 Subject: [PATCH 6/6] docs(policy): clarify tls skip credential boundary Signed-off-by: Prekshi Vyas --- .../customize-network-policy.mdx | 50 ++++++++++++------- .../integration-policy-examples.mdx | 8 ++- docs/reference/commands.mdx | 10 +++- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 7f6934a8bce..3872a639a2d 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -148,15 +148,18 @@ Provider-composed `_provider_*` entries are excluded because OpenShell reserves Existing presets and the baseline remain in place. The preset file under `presets/` also persists across sandbox recreations. -### Custom Recipe: Raw TLS Passthrough with `tls: skip` +### Custom Recipe for Raw TLS Passthrough with `tls: skip` -OpenShell's egress proxy terminates TLS for allowed HTTPS endpoints and re-originates a new handshake toward the upstream so it can inspect the traffic, and it does this even when the endpoint declares `access: full`. -Some CDN-fronted upstreams — Cloudflare-fronted APIs are the common case — reset that re-originated handshake, so the connection fails even though the host is in the allowlist. -The symptom is a reset or abort during the TLS handshake against a host that `$$nemoclaw my-assistant policy-list` shows as allowed, for example `curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL` or `ECONNRESET` from a client library. -Protocols that need to negotiate directly with the origin inside TLS break the same way: the proxy negotiates h2 ALPN when it terminates TLS, which is why WhatsApp's HTTP/1.1-only Noise-over-WebSocket handshake cannot pass through it. +OpenShell's egress proxy terminates TLS for allowed HTTPS endpoints so it can inspect traffic. +The proxy creates a new TLS connection to the upstream, including for endpoints with `access: full`. +Some Cloudflare-fronted upstreams reset that new handshake. +The host remains allowed, but clients report `ECONNRESET` or a TLS error such as `curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL`. +Protocols that require direct TLS negotiation can fail in the same way. +For example, WhatsApp's HTTP/1.1-only Noise-over-WebSocket handshake cannot use the proxy's HTTP/2 ALPN negotiation. -Declare the endpoint as a raw L4 CONNECT tunnel with `access: full` and `tls: skip`. -The proxy then passes the encrypted bytes through unmodified, and the client inside the sandbox negotiates TLS directly with the origin: +Use a raw L4 CONNECT tunnel when the endpoint requires direct TLS negotiation. +Set `access: full` and `tls: skip`. +The proxy passes the encrypted bytes without modification, and the sandbox client negotiates TLS with the upstream: ```yaml preset: @@ -174,24 +177,33 @@ network_policies: - { path: /usr/local/bin/node } ``` -Save the file as `nemoclaw-blueprint/policies/presets/cf-fronted-api.yaml` so it joins the preset catalog, then apply it to the running sandbox by name: +Save the file as `nemoclaw-blueprint/policies/presets/cf-fronted-api.yaml`. +The filename without `.yaml` must match `preset.name`. +Apply the catalog preset to the running sandbox by name: ```bash $$nemoclaw my-assistant policy-add cf-fronted-api ``` -The filename without `.yaml` must match `preset.name`. -The preset catalog lists the `preset.name` header, but `policy-add ` loads `presets/.yaml` by filename, so a preset saved under a mismatched filename appears in the catalog yet reports `Preset not found` and applies nothing. -If you edit the preset file later, re-run the same command: `policy-add` compares the preset with the live policy and re-applies it when they no longer match. +The preset catalog reads `preset.name`, but `policy-add ` loads `presets/.yaml`. +If these values differ, the catalog can list a preset that `policy-add` cannot load. +After you edit the preset file, run the same command again. +`policy-add` compares the preset with the live policy and applies the changed content. -The `whatsapp` channel preset (`src/lib/messaging/channels/whatsapp/policy/openclaw.yaml`) uses the same shape for `web.whatsapp.com`, whose Noise handshake does not survive TLS re-origination. -Use it as a reference for combining `tls: skip` tunnel endpoints with ordinary inspected `protocol: rest` endpoints in one preset. +The maintained `whatsapp` channel preset uses this structure for `web.whatsapp.com`. +Refer to `src/lib/messaging/channels/whatsapp/policy/openclaw.yaml` for an example. +It combines `tls: skip` tunnel endpoints with inspected `protocol: rest` endpoints. -`tls: skip` disables L7 inspection for that endpoint. -The proxy cannot see or filter the HTTP method, path, or body inside the tunnel, so `rules` cannot constrain what the agent sends once the tunnel is established. -Only the policy's declared host, port, and `binaries` scoping remains for that endpoint. -Scope `tls: skip` to the specific hosts that need it — never a broad wildcard — and keep the default inspected path for every endpoint that tolerates it. +`tls: skip` disables L7 inspection and egress-boundary credential resolution for that endpoint. +The proxy cannot filter the HTTP method, path, or body after it creates the tunnel. +Endpoint `rules` therefore cannot constrain what the agent sends through the tunnel. +The proxy also cannot replace an OpenShell credential placeholder inside the encrypted request. +This recipe does not unblock endpoints that require both raw passthrough and egress-boundary credential resolution. +The declared host, port, and `binaries` scope remain in effect. +Use `tls: skip` only for the exact hosts that require raw passthrough. +Do not use a broad wildcard. +Keep L7 inspection for every endpoint that supports it. ### Custom Recipe: URL-Based MCP Server @@ -229,7 +241,9 @@ network_policies: - { path: /usr/local/bin/node } ``` -Save it as `nemoclaw-blueprint/policies/presets/my-mcp.yaml` (the filename without `.yaml` must match `preset.name`) and apply it to the running sandbox by name: +Save it as `nemoclaw-blueprint/policies/presets/my-mcp.yaml`. +The filename without `.yaml` must match `preset.name`. +Apply it to the running sandbox by name: ```bash $$nemoclaw my-assistant policy-add my-mcp diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index 5d31c86a322..9d448f2ef22 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -100,8 +100,12 @@ $$nemoclaw my-assistant policy-add outlook --dry-run $$nemoclaw my-assistant policy-add outlook --yes ``` -Re-running `policy-add outlook` when the preset is already applied is a safe no-op: the command compares the preset with the live policy, exits zero when they match, and re-applies the preset whenever they no longer match, including after the preset file changes. -You do not need to remove the preset first; removing it only to re-add it needlessly drops the entries from the live policy in between. +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. +You do not need to remove the preset before you apply it again. +Removing it first temporarily deletes its entries from the live policy. Then configure the email or calendar tool credentials through the integration you run in the sandbox. Keep OAuth client secrets and refresh tokens out of policy files. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 9cc054f0cb4..da6ec6720ee 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1685,8 +1685,14 @@ $$nemoclaw my-assistant policy-add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. If the preset name is unknown, the command exits non-zero with a clear error. -Naming a preset that is already applied is drift-aware: when the preset content still matches the live policy, the command reports the no-op and exits zero; when the preset no longer matches the live policy (for example after the preset file was edited), the command re-applies it through the normal preview and confirmation flow. -If the preset content or the live policy cannot be read for that comparison, or the name belongs to a custom preset applied with `--from-file`, the command exits non-zero instead of guessing. +If a named preset is already applied, the command compares the preset content with the live policy. +When the content matches, the command reports no changes and exits zero. +When the content differs, the command shows the normal preview and asks for confirmation before applying the preset again. +This includes changes to the preset file. +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. 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.