Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
117c96a
fix(policy): re-apply edited presets in policy-add and document tls s…
Dongni-Yang Jul 22, 2026
ee791cb
test(policy): keep policies.test.ts within its size budget
Dongni-Yang Jul 22, 2026
5adddbb
test(policy): keep drift-suite test bodies free of if statements
Dongni-Yang Jul 22, 2026
2c47a2b
docs(policy): keep tls skip recipe out of the MCP round-trip test slice
Dongni-Yang Jul 22, 2026
04ec0c3
docs(policy): describe drift re-apply as any live-policy mismatch
Dongni-Yang Jul 22, 2026
7464ebd
Merge remote-tracking branch 'origin/main' into dongniy/7323-policy-a…
Dongni-Yang Jul 22, 2026
80954b6
Merge branch 'main' into dongniy/7323-policy-add-drift-docs
Dongni-Yang Jul 22, 2026
a2dc594
Merge branch 'main' into dongniy/7323-policy-add-drift-docs
Dongni-Yang Jul 22, 2026
7255254
Merge branch 'main' into dongniy/7323-policy-add-drift-docs
Dongni-Yang Jul 22, 2026
309dd65
Merge branch 'main' into dongniy/7323-policy-add-drift-docs
Dongni-Yang Jul 23, 2026
5f26a39
merge(main): refresh policy drift fix
apurvvkumaria Jul 23, 2026
1d1b9bc
Merge branch 'main' into dongniy/7323-policy-add-drift-docs
cv Jul 23, 2026
dd6cf49
Merge branch 'main' into dongniy/7323-policy-add-drift-docs
cv Jul 23, 2026
01f66dc
Merge branch 'main' into dongniy/7323-policy-add-drift-docs
prekshivyas Jul 23, 2026
a75b3a0
docs(policy): clarify tls skip credential boundary
prekshivyas Jul 23, 2026
daccaac
merge(main): refresh policy drift fix
prekshivyas Jul 24, 2026
52ce7bf
merge(main): refresh policy drift fix
prekshivyas Jul 24, 2026
e4fb50d
merge(main): synchronize policy drift fix
prekshivyas Jul 24, 2026
56be22d
merge(main): synchronize policy drift fix
prekshivyas Jul 24, 2026
8225e7d
merge(main): synchronize policy drift fix
prekshivyas Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions docs/network-policy/customize-network-policy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` loads `presets/<name>.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.

<Warning>
`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.
</Warning>

### 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.
Expand Down Expand Up @@ -183,13 +229,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.

<Note>
Expand Down
3 changes: 2 additions & 1 deletion docs/network-policy/integration-policy-examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Keep OAuth client secrets and refresh tokens out of policy files.
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
243 changes: 243 additions & 0 deletions src/lib/actions/sandbox/policy-channel-add-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Drift-aware `policy-add <preset>` 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<typeof policies.listPresets>[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<void>): Promise<number | undefined> {
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(() => {
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);
});
});
Loading
Loading