Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@ If the container runtime restricts `ulimit` modification, the entrypoint logs a

Managed Deep Agents uses stricter enforcement than the best-effort entrypoint behavior described above.
The managed Deep Agents image applies the 512-process and 65,536-file-descriptor caps to the long-running sandbox entrypoint tree and to direct managed `dcode` launches.
During Docker-driver onboarding, NemoClaw also configures the container with exact `nproc=512:512` and `nofile=65536:65536` limits so the managed entrypoint and fresh exec or connect processes start under the same hard caps.
The container-level limits and managed startup command remain in effect when you stop and start the sandbox or restart the OpenShell gateway.
It also applies and verifies the caps in Bash login and interactive shells because fresh `openshell sandbox exec` and connect shells do not inherit the dcode entrypoint child's lowered limits.
For managed Deep Agents, successful verification requires both the soft and hard limits to equal 512 processes and 65,536 file descriptors; a lower inherited file-descriptor default such as 1,024 is not accepted as successful hardening.

Expand All @@ -524,7 +526,7 @@ The entrypoint and launcher complete this verification before proxy setup or use
A login or interactive shell remains available when the helper is missing or its limits cannot be verified, but it prints `[SECURITY] Sandbox resource limits were NOT hardened for this shell.`
OpenShell creates those fresh exec and connect processes outside the entrypoint tree, so an image layer cannot make them inherit the entrypoint child's lowered limits.
The shell compatibility exception can be removed when OpenShell guarantees that every exec and connect process starts under enforced caps or exposes a fail-closed resource-limit contract.
Set hard `nproc` and `nofile` limits at the container runtime when you require fail-closed enforcement.
For custom container launches outside NemoClaw's managed Docker-driver onboarding path, set hard `nproc` and `nofile` limits at the container runtime when you require fail-closed enforcement.

### Deep Agents Thread Auto-Approval Capability

Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2801,7 +2801,7 @@ async function createSandboxWithBaseImageResolution(
prebuild,
restoreBackupPath,
terminalAgent: agentDefs.isTerminalAgent(agent),
persistStartupCommand: dockerDriverGateway === true && agent?.name === "hermes",
...sandboxGpuCreateFlow.resolveDockerStartupCommandPatch(agent, dockerDriverGateway),
},
{
runOpenshell,
Expand Down
27 changes: 27 additions & 0 deletions src/lib/onboard/docker-gpu-patch-clone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,33 @@ describe("Docker GPU clone envelope", () => {
);
});

it("preserves inspected ulimits and overrides DCode's exact required limits", () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const inspect = inspectFixture();
inspect.HostConfig!.Ulimits = [
{ Name: "core", Soft: 0, Hard: -1 },
{ Name: "nofile", Soft: 1024, Hard: 1024 },
];

const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("startup-command"), {
requiredUlimits: [
{ name: "nproc", soft: 512, hard: 512 },
{ name: "nofile", soft: 65_536, hard: 65_536 },
],
});

expect(args).toEqual(
expect.arrayContaining([
"--ulimit",
"core=0:-1",
"--ulimit",
"nofile=65536:65536",
"--ulimit",
"nproc=512:512",
]),
);
expect(args).not.toContain("nofile=1024:1024");
});

it("adds SYS_PTRACE to the GPU clone when the baseline container lacks it", () => {
const inspect = inspectFixture();
inspect.HostConfig!.CapAdd = ["SYS_ADMIN", "NET_ADMIN"];
Expand Down
54 changes: 54 additions & 0 deletions src/lib/onboard/docker-gpu-patch-clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
DockerContainerInspect,
DockerGpuCloneRunOptions,
DockerGpuPatchMode,
DockerUlimit,
} from "./docker-gpu-patch-types";
import { openshellSandboxCommandEnvValue } from "./docker-startup-command-env";

Expand Down Expand Up @@ -64,6 +65,56 @@ function pushStringFlag(args: string[], flag: string, value: unknown): void {
if (normalized) args.push(flag, normalized);
}

function normalizeRequiredUlimit(ulimit: DockerUlimit): DockerUlimit {
const name = String(ulimit.name).trim();
if (!/^[a-z][a-z0-9_]*$/u.test(name)) {
throw new Error(`Invalid Docker ulimit name '${name}'.`);
}
if (
!Number.isSafeInteger(ulimit.soft) ||
ulimit.soft < 0 ||
!Number.isSafeInteger(ulimit.hard) ||
ulimit.hard < ulimit.soft
) {
throw new Error(`Invalid Docker ulimit values for '${name}'.`);
}
return { name, soft: ulimit.soft, hard: ulimit.hard };
}

export function validateRequiredDockerUlimits(
required: readonly DockerUlimit[] | null | undefined,
): void {
for (const ulimit of required ?? []) normalizeRequiredUlimit(ulimit);
}

function dockerUlimits(
inspect: DockerContainerInspect,
required: readonly DockerUlimit[] | null | undefined,
): DockerUlimit[] {
const merged = new Map<string, DockerUlimit>();
for (const ulimit of inspect.HostConfig?.Ulimits ?? []) {
const name = String(ulimit.Name ?? "").trim();
const soft = ulimit.Soft;
const hard = ulimit.Hard;
if (
!name ||
!Number.isSafeInteger(soft) ||
(soft as number) < -1 ||
!Number.isSafeInteger(hard) ||
(hard as number) < -1 ||
((hard as number) !== -1 && (soft as number) > (hard as number))
) {
continue;
}
merged.set(name, { name, soft: soft as number, hard: hard as number });
}
for (const ulimit of required ?? []) {
const normalized = normalizeRequiredUlimit(ulimit);
merged.set(normalized.name, normalized);
}
return [...merged.values()];
}

function pushNumberFlag(args: string[], flag: string, value: unknown): void {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
args.push(flag, String(value));
Expand Down Expand Up @@ -217,6 +268,9 @@ export function buildDockerGpuCloneRunArgs(
args.push("--group-add", normalized);
}
}
for (const ulimit of dockerUlimits(inspect, options.requiredUlimits)) {
args.push("--ulimit", `${ulimit.name}=${ulimit.soft}:${ulimit.hard}`);
}
if (networkMode !== "host") {
const dnsServers = stringArray(host.Dns);
for (const dns of dnsServers) args.push("--dns", dns);
Expand Down
4 changes: 4 additions & 0 deletions src/lib/onboard/docker-gpu-patch-recreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
dockerContainerName,
parseDockerInspectJson,
sameContainerId,
validateRequiredDockerUlimits,
} from "./docker-gpu-patch-clone";
import {
DOCKER_GPU_PATCH_STOP_TIMEOUT_MS,
Expand Down Expand Up @@ -151,6 +152,7 @@ export function recreateOpenShellDockerSandboxContainer(
timeoutSecs?: number;
waitForSupervisor?: boolean;
openshellSandboxCommand?: readonly string[] | null;
requiredUlimits?: readonly import("./docker-gpu-patch-types").DockerUlimit[] | null;
expectedOldContainerId?: string | null;
backend?: "generic" | "jetson";
dockerDesktopWsl?: boolean;
Expand All @@ -164,6 +166,7 @@ export function recreateOpenShellDockerSandboxContainer(
modeAttempts: [],
};
try {
validateRequiredDockerUlimits(options.requiredUlimits);
const containerIds = findOpenShellDockerSandboxContainerIds(options.sandboxName, deps);
const oldContainerId = containerIds[0];
if (!oldContainerId) {
Expand Down Expand Up @@ -228,6 +231,7 @@ export function recreateOpenShellDockerSandboxContainer(
const cloneOptions = buildDockerGpuCloneRunOptions(inspect);
cloneOptions.image = image;
cloneOptions.openshellSandboxCommand = options.openshellSandboxCommand ?? null;
cloneOptions.requiredUlimits = options.requiredUlimits ?? null;
const sandboxFallbackDns = d.detectSandboxFallbackDns();
if (sandboxFallbackDns) cloneOptions.sandboxFallbackDns = sandboxFallbackDns;
if (selection.mode.kind !== "startup-command" && options.backend === "jetson") {
Expand Down
12 changes: 12 additions & 0 deletions src/lib/onboard/docker-gpu-patch-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,19 @@ export type DockerGpuPatchResult = {
backupRemoved: boolean;
};

export type DockerUlimit = {
name: string;
soft: number;
hard: number;
};

export type DockerGpuCloneRunOptions = {
image?: string | null;
networkMode?: string | null;
openshellEndpoint?: string | null;
sandboxFallbackDns?: string | null;
openshellSandboxCommand?: readonly string[] | null;
requiredUlimits?: readonly DockerUlimit[] | null;
/**
* Extra supplementary group IDs to add to the recreated container via
* `--group-add`. On Jetson these are the host group(s) owning the Tegra GPU
Expand Down Expand Up @@ -201,6 +208,11 @@ export type DockerContainerInspect = {
IpcMode?: string;
PidMode?: string;
GroupAdd?: string[] | null;
Ulimits?: Array<{
Name?: string;
Soft?: number;
Hard?: number;
}> | null;
Dns?: string[] | null;
DnsSearch?: string[] | null;
DeviceRequests?: Array<{
Expand Down
36 changes: 36 additions & 0 deletions src/lib/onboard/docker-gpu-patch-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,40 @@ describe("Docker GPU startup command validation (#6110)", () => {
expect(dockerRename).not.toHaveBeenCalled();
expect(dockerRunDetached).not.toHaveBeenCalled();
});

it("rejects malformed required ulimits before touching the original container", () => {
const dockerStop = vi.fn(() => ({ status: 0 }));
const dockerRun = vi.fn(() => ({ status: 0, stdout: "probe-id\n" }));
const dockerRunDetached = vi.fn(() => ({ status: 0, stdout: "new-container-id\n" }));

expect(() =>
recreateOpenShellDockerSandboxWithGpu(
{
sandboxName: "alpha",
timeoutSecs: 1,
requiredUlimits: [{ name: "nofile;id", soft: 65_536, hard: 65_536 }],
},
{
dockerCapture: vi.fn((args: readonly string[]) =>
args[0] === "ps"
? "old-container-id\n"
: args[0] === "inspect"
? JSON.stringify([inspectFixture()])
: "",
),
detectSandboxFallbackDns: vi.fn(() => null),
dockerRun,
dockerRunDetached,
dockerRename: vi.fn(() => ({ status: 0 })),
dockerRm: vi.fn(() => ({ status: 0 })),
dockerStop,
readDir: vi.fn(() => null),
readFile: vi.fn(() => null),
},
),
).toThrow("Invalid Docker ulimit name");
expect(dockerRun).not.toHaveBeenCalled();
expect(dockerStop).not.toHaveBeenCalled();
expect(dockerRunDetached).not.toHaveBeenCalled();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
2 changes: 2 additions & 0 deletions src/lib/onboard/docker-gpu-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
DockerGpuPatchMode,
DockerGpuPatchResult,
DockerGpuPatchSandboxSnapshot,
DockerUlimit,
} from "./docker-gpu-patch-types";

export { detectSandboxFallbackDns } from "./docker-gpu-dns-fallback";
Expand Down Expand Up @@ -77,6 +78,7 @@ export type {
DockerGpuPatchModeKind,
DockerGpuPatchResult,
DockerGpuPatchSandboxSnapshot,
DockerUlimit,
} from "./docker-gpu-patch-types";
export {
findOpenShellDockerSandboxContainerIds,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/onboard/docker-gpu-sandbox-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type DockerGpuSandboxCreatePatchOptions = {
sandboxName: string;
gpuDevice?: string | null;
openshellSandboxCommand?: readonly string[] | null;
requiredUlimits?: Parameters<RecreateStartupPatchFn>[0]["requiredUlimits"];
timeoutSecs: number;
backend?: DockerGpuPatchBackend;
/**
Expand Down Expand Up @@ -139,6 +140,7 @@ export function createDockerGpuSandboxCreatePatch(
sandboxName: options.sandboxName,
gpuDevice: options.gpuDevice,
openshellSandboxCommand: options.openshellSandboxCommand ?? null,
requiredUlimits: options.requiredUlimits ?? null,
timeoutSecs: options.timeoutSecs,
backend: options.backend,
dockerDesktopWsl: options.dockerDesktopWsl ?? isDockerDesktopWslRuntime(),
Expand All @@ -149,6 +151,7 @@ export function createDockerGpuSandboxCreatePatch(
gpuEnabled: routeAdapter.enabled,
gpuOptions: applyOptions,
startupCommand: options.openshellSandboxCommand,
requiredUlimits: options.requiredUlimits,
recreateGpu: recreatePatch,
recreateStartup: recreateStartupPatch,
});
Expand Down
32 changes: 32 additions & 0 deletions src/lib/onboard/docker-startup-command-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { AgentDefinition } from "../agent/defs";
import type { DockerUlimit } from "./docker-gpu-patch-types";

const DCODE_AGENT_NAME = "langchain-deepagents-code";

// DCode's managed entrypoint fails closed unless both limits are exact. Set
// them on the Docker container so the OpenShell supervisor and every child
// inherit the contract, including after container and gateway restarts.
export const DCODE_DOCKER_ULIMITS: readonly DockerUlimit[] = [
{ name: "nproc", soft: 512, hard: 512 },
{ name: "nofile", soft: 65_536, hard: 65_536 },
];

export function resolveDockerStartupCommandPatch(
agent: AgentDefinition | null | undefined,
dockerDriverGateway: boolean | null | undefined,
): {
persistStartupCommand: boolean;
requiredUlimits: readonly DockerUlimit[] | null;
} {
if (dockerDriverGateway !== true) {
return { persistStartupCommand: false, requiredUlimits: null };
}
const agentName = agent?.name;
return {
persistStartupCommand: agentName === "hermes" || agentName === DCODE_AGENT_NAME,
requiredUlimits: agentName === DCODE_AGENT_NAME ? DCODE_DOCKER_ULIMITS : null,
};
}
1 change: 1 addition & 0 deletions src/lib/onboard/docker-startup-command-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function recreateOpenShellDockerSandboxWithStartupCommand(
timeoutSecs?: number;
waitForSupervisor?: boolean;
openshellSandboxCommand: readonly string[];
requiredUlimits?: readonly import("./docker-gpu-patch-types").DockerUlimit[] | null;
expectedOldContainerId?: string | null;
},
deps: DockerGpuPatchDeps = {},
Expand Down
15 changes: 13 additions & 2 deletions src/lib/onboard/docker-startup-command-sandbox-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("Docker startup-command sandbox creation", () => {
vi.restoreAllMocks();
});

it("uses the default startup-command recreation path for non-GPU Hermes containers", () => {
it("uses the startup-command recreation path with DCode's exact resource limits", () => {
const dockerCaptureOutput: Record<string, string> = {
ps: "old-container-id\n",
inspect: JSON.stringify([inspectFixture()]),
Expand All @@ -90,6 +90,10 @@ describe("Docker startup-command sandbox creation", () => {
persistStartupCommand: true,
sandboxName: "alpha",
openshellSandboxCommand: ["env", "nemoclaw-start"],
requiredUlimits: [
{ name: "nproc", soft: 512, hard: 512 },
{ name: "nofile", soft: 65_536, hard: 65_536 },
],
timeoutSecs: 60,
deps,
overrides: {
Expand All @@ -101,7 +105,14 @@ describe("Docker startup-command sandbox creation", () => {

expect(recreatePatch).not.toHaveBeenCalled();
expect(dockerRunDetached.mock.calls[0]?.[0]).toEqual(
expect.arrayContaining(["--env", "OPENSHELL_SANDBOX_COMMAND=env nemoclaw-start"]),
expect.arrayContaining([
"--env",
"OPENSHELL_SANDBOX_COMMAND=env nemoclaw-start",
"--ulimit",
"nproc=512:512",
"--ulimit",
"nofile=65536:65536",
]),
);
expect(patch.selectedMode()?.kind).toBe("startup-command");
});
Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard/docker-startup-command-sandbox-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function createDockerSandboxRecreator(options: {
gpuEnabled: boolean;
gpuOptions: Parameters<RecreateGpuPatchFn>[0];
startupCommand: readonly string[] | null | undefined;
requiredUlimits?: Parameters<RecreateStartupPatchFn>[0]["requiredUlimits"];
recreateGpu?: RecreateGpuPatchFn;
recreateStartup?: RecreateStartupPatchFn;
}): (waitForSupervisor: boolean, deps: DockerGpuPatchDeps) => DockerGpuPatchResult {
Expand All @@ -29,6 +30,7 @@ export function createDockerSandboxRecreator(options: {
{
sandboxName: options.gpuOptions.sandboxName,
openshellSandboxCommand: options.startupCommand || [],
requiredUlimits: options.requiredUlimits,
timeoutSecs: options.gpuOptions.timeoutSecs,
waitForSupervisor,
},
Expand Down
Loading
Loading