Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
53170ea
fix(policy): suppress agent-required preset additions on restricted tier
laitingsheng Jun 25, 2026
249a237
fix(policy): enforce restricted-tier preset suppression at merge boun…
laitingsheng Jun 26, 2026
5c17ae7
test(policy): use ternary helper to keep test if-count stable for gro…
laitingsheng Jun 26, 2026
4b2face
style(policy): apply biome formatting to tier-onboard tests
laitingsheng Jun 26, 2026
56c2137
fix(policy): apply restricted-tier suppression after preservation loop
laitingsheng Jun 26, 2026
07c4899
fix(policy): gate restricted resume reapply via recorded tier lookup
laitingsheng Jun 26, 2026
a0f9089
style(onboard): fold tier accessor arrows onto single lines
laitingsheng Jun 26, 2026
4c83ae3
fix(policy): plumb tier into merge helper and resume preparation
laitingsheng Jun 26, 2026
76be439
refactor(policy): move resume tier injection into policy handler
laitingsheng Jun 26, 2026
f599cf7
style(policy): apply biome formatting to interactive-choice block
laitingsheng Jun 26, 2026
b28ce1b
fix(policy): force setup reconciliation when suppressed presets are l…
laitingsheng Jun 26, 2026
85c68cd
test: update onboard-final-flow-phases helper for new PolicyResumeSel…
laitingsheng Jun 26, 2026
2992699
docs(policy): explain handler-level fallback rationale for restricted…
laitingsheng Jun 26, 2026
d6d99c0
fix(policy): suppress stale otel-local on restricted regardless of cu…
laitingsheng Jun 27, 2026
52c01fa
refactor(policy): extract tier-suppression module, add default-restri…
laitingsheng Jun 27, 2026
dc3c2a3
refactor(e2e): extract restricted onboard retry helper to keep growth…
laitingsheng Jun 27, 2026
0285b83
refactor(policy): offset hotspot growth, clarify preservation, add in…
laitingsheng Jun 27, 2026
d9a6218
test(policy): cover tier plumbing + drift invariant, offset note form…
laitingsheng Jun 27, 2026
34c7036
style(policy): condense resume tier comment
laitingsheng Jun 27, 2026
78edbb9
refactor(policy): extract resume helper into module, add tier-switch …
laitingsheng Jun 27, 2026
1db2436
fix(policy): drop unused suppressedAgentRequiredPresets import flagge…
laitingsheng Jun 27, 2026
4bec3af
fix(policy): suppress create-time openclaw OTEL preset on restricted …
laitingsheng Jun 27, 2026
b985d78
refactor(onboard): inline policy tier env reader to net-neutralise on…
laitingsheng Jun 27, 2026
6578d78
refactor(onboard): default policyTier read into sandbox-create-plan t…
laitingsheng Jun 27, 2026
1aacda7
Merge remote-tracking branch 'origin/main' into fix/restricted-tier-s…
laitingsheng Jun 27, 2026
2378c0c
fix(onboard): inline policy tier env reader in sandbox-create-plan to…
laitingsheng Jun 27, 2026
bea83ad
fix(policy): fail-closed create-time OTEL preset — require known non-…
laitingsheng Jun 27, 2026
3848465
fix(policy): only trust NEMOCLAW_POLICY_TIER env in non-interactive c…
laitingsheng Jun 27, 2026
7b83f85
merge: sync fix/restricted-tier-strict-presets with main
prekshivyas Jun 30, 2026
b6a6180
merge(policy): sync restricted-tier branch with main
laitingsheng Jun 30, 2026
a24b144
test(policy): align handler resume mock with PolicyResumeSelection
laitingsheng Jun 30, 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
2 changes: 1 addition & 1 deletion docs/reference/network-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The baseline policy is always applied regardless of the selected tier.

| Tier | Presets included | Description |
|------|------------------|-------------|
| Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. |
| Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. Restricted mode suppresses agent-required preset additions, such as OpenClaw pricing fetches; reapply them later with `policy-add` if cost recording or other agent-side features are needed. |
| Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported` | Full dev tooling and web search for agents that support web search. No messaging platform access. Apply the `weather` preset explicitly if your agent needs read-only weather lookups. |
| Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `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. |

Expand Down
2 changes: 1 addition & 1 deletion nemoclaw-blueprint/policies/tiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
tiers:
- name: restricted
label: Restricted
description: Base sandbox only. No third-party network access beyond inference and core agent tooling.
description: Base sandbox only. No third-party network access beyond inference and core agent tooling. Restricted mode suppresses agent-required preset additions, such as OpenClaw pricing fetches; reapply them later with policy-add if cost recording or other agent-side features are needed.
presets: []

- name: balanced
Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4571,8 +4571,8 @@ async function setupPoliciesWithSelection(
waitForSandboxReady,
syncPresetSelection,
selectPolicyTier,
setPolicyTier: (sandbox, tierName) =>
registry.updateSandbox(sandbox, { policyTier: tierName }),
setPolicyTier: (s, t) => registry.updateSandbox(s, { policyTier: t }),
getRecordedPolicyTier: (s) => registry.getSandbox(s)?.policyTier ?? null,
selectTierPresetsAndAccess,
parsePolicyPresetEnv,
env: process.env,
Expand Down
44 changes: 43 additions & 1 deletion src/lib/onboard/initial-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,21 +265,35 @@ network_policies:
expect(prepared.cleanup?.()).toBe(true);
});

it("merges openclaw-diagnostics-otel-local at create time when OTEL is enabled", () => {
it("merges openclaw-diagnostics-otel-local at create time when OTEL is enabled and the tier is known non-restricted", () => {
const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n");
process.env.NEMOCLAW_OPENCLAW_OTEL = "1";
process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318";
delete process.env.NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME;
delete process.env.NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE;
const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, [], {
agentName: "openclaw",
policyTier: "balanced",
});

expect(prepared.appliedPresets).toEqual(["openclaw-diagnostics-otel-local"]);
expect(prepared.policyPath).not.toBe(basePolicyPath);
expect(prepared.cleanup?.()).toBe(true);
});

it("defers openclaw-diagnostics-otel-local at create time when the tier is unknown (interactive flow)", () => {
const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n");
process.env.NEMOCLAW_OPENCLAW_OTEL = "1";
process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318";

const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, [], {
agentName: "openclaw",
});

expect(prepared.appliedPresets).toEqual([]);
expect(prepared.policyPath).toBe(basePolicyPath);
});

it("does not merge OpenClaw OTEL policy at create time for terminal agents", () => {
const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n");
process.env.NEMOCLAW_OPENCLAW_OTEL = "1";
Expand All @@ -293,4 +307,32 @@ network_policies:
expect(prepared.policyPath).toBe(basePolicyPath);
expect(prepared.cleanup).toBeUndefined();
});

it("suppresses openclaw-diagnostics-otel-local at create time on the restricted tier (defence-in-depth)", () => {
const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n");
process.env.NEMOCLAW_OPENCLAW_OTEL = "1";
process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318";

const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, [], {
agentName: "openclaw",
policyTier: "restricted",
});

expect(prepared.appliedPresets).toEqual([]);
expect(prepared.policyPath).toBe(basePolicyPath);
});

it("keeps openclaw-diagnostics-otel-local at create time on the balanced tier when OTEL is enabled", () => {
const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n");
process.env.NEMOCLAW_OPENCLAW_OTEL = "1";
process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318";

const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, [], {
agentName: "openclaw",
policyTier: "balanced",
});

expect(prepared.appliedPresets).toEqual(["openclaw-diagnostics-otel-local"]);
expect(prepared.cleanup?.()).toBe(true);
});
});
32 changes: 25 additions & 7 deletions src/lib/onboard/initial-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getMessagingPolicyKeysByChannel } from "../messaging/channels";
import * as policies from "../policy";
import { requiredMessagingChannelPolicyPresets } from "./messaging-policy-presets";
import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets";
import { filterSuppressedAgentRequiredPresets } from "./policy-tier-suppression";
import { cleanupTempDir, secureTempFile } from "./temp-files";

export type InitialSandboxPolicy = {
Expand Down Expand Up @@ -203,6 +204,7 @@ export function prepareInitialSandboxCreatePolicy(
dockerGpuPatch?: boolean;
additionalPresets?: string[];
agentName?: string | null;
policyTier?: string | null;
} = {},
): InitialSandboxPolicy {
const directGpuPolicy = options.directGpu
Expand All @@ -214,13 +216,29 @@ export function prepareInitialSandboxCreatePolicy(
const cleanupFns = directGpuPolicy?.cleanup ? [directGpuPolicy.cleanup] : [];
const buildCleanup = () =>
cleanupFns.length > 0 ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined;
const requestedCreateTimePresets = [
...new Set([
...requiredMessagingChannelPolicyPresets(activeMessagingChannels),
...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"),
...(options.additionalPresets || []),
]),
];
// Fail closed: the OpenClaw OTEL preset is added at create time only when the
// selected policy tier is known and is not Restricted. When the tier is null
// (interactive flow that selects later) the preset is deferred to the
// post-boot policy step, so a later Restricted selection cannot leave a
// transient host-local OTLP egress allowance during sandbox boot. The same
// suppression filter still runs so an explicit `policyTier: "restricted"`
// (non-interactive flow) drops openclaw-pricing from `additionalPresets`.
const tierKnown = typeof options.policyTier === "string" && options.policyTier.length > 0;
const otelCreateTimePresets =
tierKnown && options.policyTier !== "restricted"
? requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw")
: [];
const requestedCreateTimePresets = filterSuppressedAgentRequiredPresets(
[
...new Set([
...requiredMessagingChannelPolicyPresets(activeMessagingChannels),
...otelCreateTimePresets,
...(options.additionalPresets || []),
]),
],
options.policyTier ?? null,
options.agentName ?? null,
);
const dedupe = (values: string[]) => [...new Set(values.filter(Boolean))];

let basePolicy = fs.readFileSync(effectiveBasePolicyPath, "utf-8");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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 } from "../../../state/onboard-session";
import { handlePoliciesState } from "./policies";
import {
basePolicyHandlerOptions as baseOptions,
createPolicyHandlerDeps as createDeps,
makeMessagingPlan,
} from "./policies-test-fixtures";

// Handler-level fallback for the runtime check the advisor calls out: the
// narrowest live assertion (read the actual OpenShell-applied preset list
// after restricted OpenClaw onboarding and confirm `openclaw-pricing` and
// `openclaw-diagnostics-otel-local` are absent) lives in the nightly
// `network-policy-vitest` scenario — that path requires real OpenShell plus
// an `nvapi-` inference key and is intentionally not run on every PR push.
// This contract test covers the handler-side reconciliation branch — that
// restricted resume forces `setupPoliciesWithSelection` to run rather than
// taking the resume-skip branch whenever
// `policyResumeSelection.suppressedAgentRequiredPresetsLive` is true — so the
// recorded-empty + live-suppressed-preset case cannot silently leave
// third-party egress active on restricted sandboxes.
// Removal condition: when the nightly live `network-policy-vitest` scenario
// asserts the actual applied preset list on restricted OpenClaw onboarding
// (both default and `NEMOCLAW_OPENCLAW_OTEL=1` cases), this handler-level
// contract test stays as the cheap reconciliation regression and the live
// scenario takes over as the source-of-truth runtime gate.
describe("handlePoliciesState — restricted resume reconciliation", () => {
it("forces setup reconciliation on restricted resume when suppressed presets are live", async () => {
const session = createSession({ policyPresets: [] });
const prepareResume = vi.fn((_sandboxName, _options) => ({
policyPresets: [],
recordedPolicyPresetsNeedReconcile: false,
disabledMessagingPolicyPresetApplied: false,
suppressedAgentRequiredPresetsLive: true,
}));
const { deps, calls, setSession } = createDeps({
preparePolicyPresetResumeSelection: prepareResume,
arePolicyPresetsApplied: vi.fn(() => true),
getActiveSandbox: vi.fn(() => ({
messaging: { plan: makeMessagingPlan("my-assistant", []) },
policyTier: "restricted",
})),
});
setSession(session);

await handlePoliciesState({ ...baseOptions(deps), resume: true });

expect(prepareResume).toHaveBeenCalledWith(
"my-assistant",
expect.objectContaining({ tierName: "restricted" }),
);
expect(calls.skipped).not.toHaveBeenCalled();
expect(calls.recordSkip).not.toHaveBeenCalled();
expect(calls.setupPolicies).toHaveBeenCalledWith(
"my-assistant",
expect.objectContaining({ selectedPresets: [] }),
);
});
});
135 changes: 135 additions & 0 deletions src/lib/onboard/machine/handlers/policies-test-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { vi } from "vitest";

import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session";
import type { PoliciesStateOptions } from "./policies";

export type PolicyTestAgent = { name: string } | null;
export type PolicyTestWebSearchConfig = { fetchEnabled: true };
type MessagingPlan = NonNullable<Session["messagingPlan"]>;
type MessagingChannelId = MessagingPlan["channels"][number]["channelId"];

export function makeMessagingPlan(
sandboxName: string,
channels: readonly MessagingChannelId[],
disabledChannels: readonly MessagingChannelId[] = [],
): MessagingPlan {
const disabled = new Set(disabledChannels);
return {
schemaVersion: 1,
sandboxName,
agent: "openclaw",
workflow: "onboard",
channels: channels.map((channelId) => ({
channelId,
displayName: channelId,
authMode: "token-paste",
active: !disabled.has(channelId),
selected: true,
configured: true,
disabled: disabled.has(channelId),
inputs: [],
hooks: [],
})),
disabledChannels,
credentialBindings: [],
networkPolicy: { presets: [], entries: [] },
agentRender: [],
buildSteps: [],
stateUpdates: [],
healthChecks: [],
};
}

export function createPolicyHandlerDeps(
overrides: Partial<PoliciesStateOptions<PolicyTestAgent, PolicyTestWebSearchConfig>["deps"]> = {},
) {
let session = createSession();
const calls = {
load: vi.fn(() => session),
activeSandbox: vi.fn(() => ({
messaging: { plan: makeMessagingPlan("my-assistant", ["telegram"]) },
})),
mergeChannels: vi.fn(
(selected: string[], recorded: string[], active: string[] | null | undefined) =>
selected.length > 0 ? selected : (active ?? recorded),
),
smoke: vi.fn(),
prepareResume: vi.fn(
(
_sandboxName: string,
options: Parameters<
PoliciesStateOptions<
PolicyTestAgent,
PolicyTestWebSearchConfig
>["deps"]["preparePolicyPresetResumeSelection"]
>[1],
) => ({
policyPresets: (options.recordedPolicyPresets ?? []).filter(
(name) => name !== "unsupported",
),
recordedPolicyPresetsNeedReconcile: (options.recordedPolicyPresets ?? []).includes(
"unsupported",
),
disabledMessagingPolicyPresetApplied: false,
suppressedAgentRequiredPresetsLive: false,
}),
),
appliedCheck: vi.fn(() => false),
skipped: vi.fn(),
recordSkip: vi.fn(async () => session),
startStep: vi.fn(async () => undefined),
setupPolicies: vi.fn(async () => ["npm"]),
updateSession: vi.fn((mutator: (value: Session) => Session | void) => {
session = mutator(session) ?? session;
return session;
}),
complete: vi.fn(async () => session),
persistPolicies: vi.fn((_sandboxName: string, _appliedPolicyPresets: string[]) => undefined),
};
return {
calls,
deps: {
loadSession: calls.load,
getActiveSandbox: calls.activeSandbox,
mergePolicyMessagingChannels: calls.mergeChannels,
verifyCompatibleEndpointSandboxSmoke: calls.smoke,
preparePolicyPresetResumeSelection: calls.prepareResume,
arePolicyPresetsApplied: calls.appliedCheck,
skippedStepMessage: calls.skipped,
recordStateSkipped: calls.recordSkip,
startRecordedStep: calls.startStep,
setupPoliciesWithSelection: calls.setupPolicies,
updateSession: calls.updateSession,
recordStepComplete: calls.complete,
toSessionUpdates: (updates: Record<string, unknown>) => updates as SessionUpdates,
persistAppliedPolicyPresets: calls.persistPolicies,
...overrides,
},
setSession(next: Session) {
session = next;
},
getSession: () => session,
};
}

export function basePolicyHandlerOptions(
deps: PoliciesStateOptions<PolicyTestAgent, PolicyTestWebSearchConfig>["deps"],
): PoliciesStateOptions<PolicyTestAgent, PolicyTestWebSearchConfig> {
return {
resume: false,
sandboxName: "my-assistant",
provider: "provider",
model: "model",
endpointUrl: "https://example.com/v1",
credentialEnv: "NVIDIA_INFERENCE_API_KEY",
selectedMessagingChannels: [],
webSearchConfig: null,
webSearchSupported: true,
hermesToolGateways: [],
agent: null,
deps,
};
}
2 changes: 2 additions & 0 deletions src/lib/onboard/machine/handlers/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function createDeps(overrides: Partial<PoliciesStateOptions<Agent, WebSearchConf
"unsupported",
),
disabledMessagingPolicyPresetApplied: false,
suppressedAgentRequiredPresetsLive: false,
}),
),
appliedCheck: vi.fn(() => false),
Expand Down Expand Up @@ -234,6 +235,7 @@ describe("handlePoliciesState", () => {
policyPresets: [...(options.recordedPolicyPresets ?? []), ...options.hermesToolGateways],
recordedPolicyPresetsNeedReconcile: false,
disabledMessagingPolicyPresetApplied: false,
suppressedAgentRequiredPresetsLive: false,
}));
const { deps, calls, setSession } = createDeps({
preparePolicyPresetResumeSelection: prepareResume,
Expand Down
Loading
Loading