From 0d569b4207e08ae4681e1678ec8e589c3652ec6c Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 6 Aug 2026 18:13:40 +0700 Subject: [PATCH 1/6] fix(policy): allow read-only dpkg package inspection --- agents/hermes/policy-additions.yaml | 1 + agents/hermes/policy-permissive.yaml | 1 + .../policy-additions.yaml | 1 + agents/openclaw/policy-permissive.yaml | 1 + docs/reference/network-policies.mdx | 5 ++- docs/security/best-practices.mdx | 2 +- .../policies/openclaw-sandbox-permissive.yaml | 1 + .../policies/openclaw-sandbox.yaml | 1 + .../initial-policy-real-policy.test.ts | 33 ++++++++++++++++++- 9 files changed, 43 insertions(+), 3 deletions(-) diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index 704b1a0757a..f9bad72ae6c 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -23,6 +23,7 @@ filesystem_policy: - /app - /etc - /var/log + - /var/lib/dpkg # Allow package-version inspection without package mutation. read_write: - /sandbox - /tmp diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index 3d0c51ac35a..50ecd2252b5 100644 --- a/agents/hermes/policy-permissive.yaml +++ b/agents/hermes/policy-permissive.yaml @@ -24,6 +24,7 @@ filesystem_policy: - /app - /etc - /var/log + - /var/lib/dpkg # Allow package-version inspection without package mutation. read_write: - /sandbox - /tmp diff --git a/agents/langchain-deepagents-code/policy-additions.yaml b/agents/langchain-deepagents-code/policy-additions.yaml index d0034c740ee..7a5c7d247c5 100644 --- a/agents/langchain-deepagents-code/policy-additions.yaml +++ b/agents/langchain-deepagents-code/policy-additions.yaml @@ -20,6 +20,7 @@ filesystem_policy: - /app - /etc - /var/log + - /var/lib/dpkg # Allow package-version inspection without package mutation. read_write: - /sandbox - /sandbox/.deepagents diff --git a/agents/openclaw/policy-permissive.yaml b/agents/openclaw/policy-permissive.yaml index acb084f0512..458e9a3bd3d 100644 --- a/agents/openclaw/policy-permissive.yaml +++ b/agents/openclaw/policy-permissive.yaml @@ -20,6 +20,7 @@ filesystem_policy: - /app - /etc - /var/log + - /var/lib/dpkg # Allow package-version inspection without package mutation. read_write: - /tmp - /dev/null diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 16a607d2217..281cf388c01 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -37,12 +37,15 @@ Deep Agents uses strict Landlock compatibility, so sandbox startup fails when Op | Path | Access | |---|---| | `/sandbox`, `/tmp`, `/dev/null`, `/dev/pts` | Read-write | -| `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log` | Read-only | +| `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log`, `/var/lib/dpkg` | Read-only | `/dev/pts` is the pseudo-terminal (devpts) directory. It is writable so PTY-based tools (`tmux`, `script`, and interactive shells) can allocate a terminal. Without it, those tools fail with `fork failed: Permission denied`. +Read-only access to `/var/lib/dpkg` lets `dpkg-query` inspect installed package metadata. +The filesystem policy does not grant write access to the package database. + The sandbox process runs as a dedicated `sandbox` user and group. Landlock LSM enforcement applies on a best-effort basis. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index c77e4b46c0f..a5dd54ed5c2 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -285,7 +285,7 @@ The container mounts system directories read-only to prevent the agent from modi | Aspect | Detail | |---|---| -| Default | `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log` are read-only. | +| Default | `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log`, and `/var/lib/dpkg` are read-only. | | What you can change | Add or remove paths in the `filesystem_policy.read_only` section of the policy file. | | Risk if relaxed | Making `/usr` or `/lib` writable lets the agent replace system binaries (such as `curl` or `node`) with trojanized versions. Making `/etc` writable lets the agent modify DNS resolution, TLS trust stores, or user accounts. | | Recommendation | Never make system paths writable. If the agent needs a writable location for generated files, use a subdirectory of `/sandbox`. | diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml index 6dd338464d9..c59265bfb70 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml @@ -25,6 +25,7 @@ filesystem_policy: - /app - /etc - /var/log + - /var/lib/dpkg # Allow package-version inspection without package mutation. read_write: - /tmp - /dev/null diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index 23700e740ea..497d8160443 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -28,6 +28,7 @@ filesystem_policy: - /app - /etc - /var/log + - /var/lib/dpkg # Allow package-version inspection without package mutation. read_write: - /tmp - /dev/null diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index 1e459230135..5145fcc6b55 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -30,7 +30,7 @@ type PolicyEntry = { }; type PolicyDocument = { - filesystem_policy?: { read_write?: string[] }; + filesystem_policy?: { read_only?: string[]; read_write?: string[] }; network_policies?: Record; }; @@ -140,6 +140,37 @@ describe("initial sandbox policy real preset merge", () => { } }); + it("grants read-only package database access in every shipping sandbox policy (#8467)", () => { + const policyCases = [ + { path: ["nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"], agent: "openclaw" }, + { + path: ["nemoclaw-blueprint", "policies", "openclaw-sandbox-permissive.yaml"], + agent: "openclaw", + }, + { path: ["agents", "openclaw", "policy-permissive.yaml"], agent: "openclaw" }, + { path: ["agents", "hermes", "policy-additions.yaml"], agent: "hermes" }, + { path: ["agents", "hermes", "policy-permissive.yaml"], agent: "hermes" }, + { + path: ["agents", "langchain-deepagents-code", "policy-additions.yaml"], + agent: "langchain-deepagents-code", + }, + ]; + + for (const policyCase of policyCases) { + const prepared = prepareInitialSandboxCreatePolicy(repoPath(...policyCase.path), [], { + agentName: policyCase.agent, + }); + const policy = readPreparedPolicy(prepared); + const readOnly = policy.filesystem_policy?.read_only ?? []; + const readWrite = policy.filesystem_policy?.read_write ?? []; + + expect(readOnly, policyCase.path.join("/")).toContain("/var/lib/dpkg"); + for (const writableAncestor of ["/", "/var", "/var/lib", "/var/lib/dpkg"]) { + expect(readWrite, policyCase.path.join("/")).not.toContain(writableAncestor); + } + } + }); + it("preserves baseline writable paths in effective OpenClaw permissive create policies", () => { const baseline = readPreparedPolicy( prepareInitialSandboxCreatePolicy( From 2ccc6be1c1dc2d4854d28478641892330972f2be Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 6 Aug 2026 04:35:11 -0700 Subject: [PATCH 2/6] test(policy): exercise dpkg database boundary Signed-off-by: Apurv Kumaria --- test/e2e/live/network-policy.test.ts | 29 ++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index 894b3a7b731..e96737a3e01 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -104,6 +104,26 @@ async function sandboxBash( }); } +async function expectPackageDatabaseReadOnly(sandbox: SandboxClient): Promise { + const probe = await sandboxBash( + sandbox, + String.raw` +set -euo pipefail +dpkg-query -W dpkg +printf 'DPKG_QUERY_OK\n' +if touch /var/lib/dpkg/nemoclaw-e2e-write-probe 2>/tmp/nemoclaw-dpkg-write-error; then + printf 'DPKG_WRITE_UNEXPECTEDLY_SUCCEEDED\n' + exit 1 +fi +printf 'DPKG_WRITE_DENIED\n' +`, + { artifactName: "tc-net-dpkg-package-database-read-only" }, + ); + expect(probe.exitCode, text(probe)).toBe(0); + expect(text(probe)).toContain("DPKG_QUERY_OK"); + expect(text(probe)).toContain("DPKG_WRITE_DENIED"); +} + async function applyPreset(host: HostCliClient, preset: string): Promise { const result = await runNemoclaw(host, [SANDBOX_NAME, "policy-add", preset, "--yes"], { artifactName: `policy-add-${preset}`, @@ -509,7 +529,7 @@ test("network-policy: restricted sandbox enforces live allow/deny policy probes" e2ePhases: [ "confirm built CLI Docker OpenShell and credential", "clear the sandbox and onboard restricted policy", - "prove zero active presets, default denial, and the weather allowlist", + "prove zero active presets, read-only package metadata, default denial, and the weather allowlist", "exercise package and SaaS policy presets", "prove dry-run and per-binary Jira approval", "verify hot reload inference exemption and SSRF guards", @@ -524,6 +544,7 @@ test("network-policy: restricted sandbox enforces live allow/deny policy probes" contracts: [ "deny-by-default egress", "restricted tier begins with zero active presets", + "package metadata is readable while package database writes remain denied (#8467)", "OpenShell 0.0.85 preserves the full denied endpoint and policy disposition through nemoclaw logs --tail 50 (#4760)", "read-only preset allowlist behavior", "weather preset allows wttr.in GET and HEAD but denies POST and unrelated hosts", @@ -645,7 +666,9 @@ test("network-policy: restricted sandbox enforces live allow/deny policy probes" // Keep the actual OpenShell boundary in the retained journey: a default // restricted onboard must have no active preset before operator mutation. - progress.phase("prove zero active presets, default denial, and the weather allowlist"); + progress.phase( + "prove zero active presets, read-only package metadata, default denial, and the weather allowlist", + ); const policyListAfterOnboard = await runNemoclaw(host, [SANDBOX_NAME, "policy-list"], { artifactName: "tc-net-01-policy-list-after-onboard", timeoutMs: SANDBOX_EXEC_TIMEOUT_MS, @@ -664,6 +687,8 @@ test("network-policy: restricted sandbox enforces live allow/deny policy probes" ).not.toBeNull(); expect(activePresets?.length, "restricted tier must begin with zero active presets").toBe(0); + await expectPackageDatabaseReadOnly(sandbox); + const denyDefault = await fetchStatus(sandbox, "https://example.com/", "tc-net-01-deny-default"); expect(denyDefault, `example.com should be blocked under restricted policy`).toMatch( /STATUS_403|ERROR_/, From 11ddd513d857f594e7e92eaaadce773c76bd5b11 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 6 Aug 2026 05:20:53 -0700 Subject: [PATCH 3/6] test(policy): prove package database enforcement Signed-off-by: Apurv Kumaria --- .../05-deepagents-code-landlock-readonly.sh | 49 ++++++++++ test/e2e/live/hermes-e2e.test.ts | 11 ++- test/e2e/live/network-policy.test.ts | 30 ++---- test/e2e/live/package-database-read-only.ts | 95 +++++++++++++++++++ 4 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 test/e2e/live/package-database-read-only.ts diff --git a/test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh b/test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh index 11e016937a6..e2cbe00c3b6 100755 --- a/test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh +++ b/test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh @@ -28,6 +28,40 @@ sandbox_exec() { openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c "$1" 2>&1 } +privileged_dpkg_sentinel() { + local action="$1" + local probe_path="$2" + NEMOCLAW_E2E_DPKG_ACTION="$action" \ + NEMOCLAW_E2E_DPKG_PROBE="$probe_path" \ + NEMOCLAW_E2E_SANDBOX_NAME="$SANDBOX_NAME" \ + node <<'NODE' +const { spawnSync } = require("node:child_process"); +const { privilegedSandboxExecArgv } = require("./dist/lib/sandbox/privileged-exec.js"); + +const action = process.env.NEMOCLAW_E2E_DPKG_ACTION; +const probe = process.env.NEMOCLAW_E2E_DPKG_PROBE; +const sandbox = process.env.NEMOCLAW_E2E_SANDBOX_NAME; +if (!probe || !sandbox || !["prepare", "cleanup"].includes(action)) process.exit(2); + +const command = + action === "prepare" + ? [ + "sh", + "-c", + 'set -eu; probe="$1"; test ! -e "$probe"; install -o sandbox -g sandbox -m 600 /dev/null "$probe"', + "sh", + probe, + ] + : ["rm", "-f", "--", probe]; +const result = spawnSync( + "docker", + privilegedSandboxExecArgv(sandbox, command, false, true), + { stdio: ["ignore", "ignore", "ignore"] }, +); +process.exit(result.status ?? 1); +NODE +} + PASSED=0 FAILED=0 @@ -66,6 +100,21 @@ else fail_test "/etc is writable under the Deep Agents Code policy: $OUT" fi +DPKG_SENTINEL="/var/lib/dpkg/nemoclaw-e2e-write-probe-${PPID:-$$}" +if privileged_dpkg_sentinel prepare "$DPKG_SENTINEL"; then + OUT=$(sandbox_exec "set -e; dpkg-query -W dpkg >/dev/null; echo DPKG_QUERY_OK; control=/tmp/nemoclaw-e2e-dpkg-write-control; printf 'control\\n' >\"\$control\"; rm -f \"\$control\"; echo CONTROL_WRITE_OK; if printf 'denied\\n' >'$DPKG_SENTINEL' 2>/dev/null; then echo DPKG_WRITE_UNEXPECTEDLY_SUCCEEDED; exit 1; fi; echo DPKG_WRITE_DENIED" || true) + if echo "$OUT" | grep -q "DPKG_QUERY_OK" && echo "$OUT" | grep -q "CONTROL_WRITE_OK" && echo "$OUT" | grep -q "DPKG_WRITE_DENIED"; then + pass "package metadata is readable and the package database remains read-only" + else + fail_test "package database read-only boundary failed" + fi + if ! privileged_dpkg_sentinel cleanup "$DPKG_SENTINEL"; then + fail_test "package database sentinel cleanup failed" + fi +else + fail_test "package database sentinel preparation failed" +fi + OUT=$(sandbox_exec "touch /tmp/deepagents-landlock-test && echo OK || echo FAILED" || true) if echo "$OUT" | grep -q "OK"; then pass "/tmp is writable for Deep Agents temporary files" diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 7635e83e36d..a903297e3b3 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -5,7 +5,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; - import { HERMES_E2E_TEST_TIMEOUT_MS } from "../../../tools/e2e/hermes-timeout-contract.mts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { resultText, shellQuote } from "../fixtures/clients/command.ts"; @@ -21,6 +20,7 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { assertHermesCliAdapterLiveContract, stripAnsi } from "./hermes-cli-adapter-live.ts"; import { HERMES_E2E_PHASES } from "./hermes-e2e-phases.ts"; import { runLaunchAgentTurn } from "./launch-agent-turn.ts"; +import { expectPackageDatabaseReadOnly } from "./package-database-read-only.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-hermes"; validateSandboxName(SANDBOX_NAME); @@ -420,6 +420,15 @@ test("hermes-e2e: install.sh onboards Hermes and proves health plus live inferen expect(policy.exitCode, resultText(policy)).toBe(0); expect(resultText(policy)).toMatch(/network_policies/i); + await expectPackageDatabaseReadOnly({ + artifactPrefix: "phase-3", + env: commandEnv(), + host, + sandbox, + sandboxName: SANDBOX_NAME, + timeoutMs: 30_000, + }); + const deniedEgress = await sandbox.exec( SANDBOX_NAME, ["curl", "-fsS", "--connect-timeout", "5", "--max-time", "15", "https://example.com/"], diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index e96737a3e01..cdcdc1ceec1 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -29,6 +29,7 @@ import { requirePolicyPresetNumber, } from "./network-policy-interactive.ts"; import { isTransientProviderValidationFailure } from "./network-policy-transient-provider.ts"; +import { expectPackageDatabaseReadOnly } from "./package-database-read-only.ts"; import { parseVerifiedActivePolicyPresets } from "./policy-list-state.ts"; import { ensureDockerAvailable, @@ -104,26 +105,6 @@ async function sandboxBash( }); } -async function expectPackageDatabaseReadOnly(sandbox: SandboxClient): Promise { - const probe = await sandboxBash( - sandbox, - String.raw` -set -euo pipefail -dpkg-query -W dpkg -printf 'DPKG_QUERY_OK\n' -if touch /var/lib/dpkg/nemoclaw-e2e-write-probe 2>/tmp/nemoclaw-dpkg-write-error; then - printf 'DPKG_WRITE_UNEXPECTEDLY_SUCCEEDED\n' - exit 1 -fi -printf 'DPKG_WRITE_DENIED\n' -`, - { artifactName: "tc-net-dpkg-package-database-read-only" }, - ); - expect(probe.exitCode, text(probe)).toBe(0); - expect(text(probe)).toContain("DPKG_QUERY_OK"); - expect(text(probe)).toContain("DPKG_WRITE_DENIED"); -} - async function applyPreset(host: HostCliClient, preset: string): Promise { const result = await runNemoclaw(host, [SANDBOX_NAME, "policy-add", preset, "--yes"], { artifactName: `policy-add-${preset}`, @@ -687,7 +668,14 @@ test("network-policy: restricted sandbox enforces live allow/deny policy probes" ).not.toBeNull(); expect(activePresets?.length, "restricted tier must begin with zero active presets").toBe(0); - await expectPackageDatabaseReadOnly(sandbox); + await expectPackageDatabaseReadOnly({ + artifactPrefix: "tc-net", + env: baseEnv(), + host, + sandbox, + sandboxName: SANDBOX_NAME, + timeoutMs: SANDBOX_EXEC_TIMEOUT_MS, + }); const denyDefault = await fetchStatus(sandbox, "https://example.com/", "tc-net-01-deny-default"); expect(denyDefault, `example.com should be blocked under restricted policy`).toMatch( diff --git a/test/e2e/live/package-database-read-only.ts b/test/e2e/live/package-database-read-only.ts new file mode 100644 index 00000000000..0c9cfdd5a56 --- /dev/null +++ b/test/e2e/live/package-database-read-only.ts @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { privilegedSandboxExecArgv } from "../../../src/lib/sandbox/privileged-exec.ts"; +import { shellQuote } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; + +type PackageDatabaseProbeOptions = { + artifactPrefix: string; + env: NodeJS.ProcessEnv; + host: HostCliClient; + sandbox: SandboxClient; + sandboxName: string; + timeoutMs: number; +}; + +function resultText(result: Pick): string { + return [result.stdout, result.stderr].filter(Boolean).join("\n"); +} + +function requireCondition(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +export async function expectPackageDatabaseReadOnly( + options: PackageDatabaseProbeOptions, +): Promise { + const sentinel = `/var/lib/dpkg/nemoclaw-e2e-write-probe-${process.pid}`; + const prepare = await options.host.command( + "docker", + privilegedSandboxExecArgv( + options.sandboxName, + [ + "sh", + "-c", + 'set -eu; probe="$1"; test ! -e "$probe"; install -o sandbox -g sandbox -m 600 /dev/null "$probe"', + "sh", + sentinel, + ], + false, + true, + ), + { + artifactName: `${options.artifactPrefix}-prepare-dpkg-landlock-sentinel`, + env: options.env, + timeoutMs: options.timeoutMs, + }, + ); + requireCondition(prepare.exitCode === 0, "package database sentinel preparation must succeed"); + + try { + const probe = await options.sandbox.execShell( + options.sandboxName, + trustedSandboxShellScript( + String.raw` +set -euo pipefail +dpkg-query -W dpkg >/dev/null +printf 'DPKG_QUERY_OK\n' +control=/tmp/nemoclaw-e2e-dpkg-write-control +printf 'control\n' >"$control" +rm -f "$control" +printf 'CONTROL_WRITE_OK\n' +if printf 'denied\n' >${shellQuote(sentinel)} 2>/dev/null; then + printf 'DPKG_WRITE_UNEXPECTEDLY_SUCCEEDED\n' + exit 1 +fi +printf 'DPKG_WRITE_DENIED\n' +`, + ), + { + artifactName: `${options.artifactPrefix}-dpkg-package-database-read-only`, + env: options.env, + timeoutMs: options.timeoutMs, + }, + ); + const output = resultText(probe); + requireCondition(probe.exitCode === 0, "package database probe must exit successfully"); + requireCondition(output.includes("DPKG_QUERY_OK"), "dpkg-query read marker is missing"); + requireCondition(output.includes("CONTROL_WRITE_OK"), "writable control marker is missing"); + requireCondition(output.includes("DPKG_WRITE_DENIED"), "Landlock denial marker is missing"); + } finally { + const cleanup = await options.host.command( + "docker", + privilegedSandboxExecArgv(options.sandboxName, ["rm", "-f", "--", sentinel], false, true), + { + artifactName: `${options.artifactPrefix}-clean-dpkg-landlock-sentinel`, + env: options.env, + timeoutMs: options.timeoutMs, + }, + ); + requireCondition(cleanup.exitCode === 0, "package database sentinel cleanup must succeed"); + } +} From 796b60a519e88573c862fc86118d7236d5d621c5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 6 Aug 2026 05:34:08 -0700 Subject: [PATCH 4/6] test(e2e): use shared command result helper Signed-off-by: Apurv Kumaria --- test/e2e/live/package-database-read-only.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/e2e/live/package-database-read-only.ts b/test/e2e/live/package-database-read-only.ts index 0c9cfdd5a56..16a403abed0 100644 --- a/test/e2e/live/package-database-read-only.ts +++ b/test/e2e/live/package-database-read-only.ts @@ -2,10 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { privilegedSandboxExecArgv } from "../../../src/lib/sandbox/privileged-exec.ts"; -import { shellQuote } from "../fixtures/clients/command.ts"; +import { resultText, shellQuote } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; -import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; type PackageDatabaseProbeOptions = { artifactPrefix: string; @@ -16,10 +15,6 @@ type PackageDatabaseProbeOptions = { timeoutMs: number; }; -function resultText(result: Pick): string { - return [result.stdout, result.stderr].filter(Boolean).join("\n"); -} - function requireCondition(condition: boolean, message: string): void { if (!condition) throw new Error(message); } From 7b9a385ec19ca03752a0ce29e841b64b3d1f34ba Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 6 Aug 2026 22:58:12 +0700 Subject: [PATCH 5/6] fix(onboard): preserve managed Docker replacement spec Signed-off-by: San Dang --- .../onboard/docker-gpu-patch-clone.test.ts | 74 +++++++++++++++++++ src/lib/onboard/docker-gpu-patch-clone.ts | 57 ++++++++++++++ src/lib/onboard/docker-gpu-patch-types.ts | 2 + .../managed-bootstrap/docker-spec.test.ts | 22 ++++++ .../onboard/managed-bootstrap/docker-spec.ts | 21 +++++- 5 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/docker-gpu-patch-clone.test.ts b/src/lib/onboard/docker-gpu-patch-clone.test.ts index a6c11ee9f84..b3bbaa0ca95 100644 --- a/src/lib/onboard/docker-gpu-patch-clone.test.ts +++ b/src/lib/onboard/docker-gpu-patch-clone.test.ts @@ -137,6 +137,80 @@ describe("Docker GPU clone envelope", () => { ); }); + it("preserves explicit Docker port bindings and exposed ports", () => { + const inspect = inspectFixture(); + inspect.Config!.ExposedPorts = { + "8080/tcp": {}, + "9090/udp": {}, + "9443/tcp": {}, + }; + inspect.HostConfig!.PortBindings = { + "8080/tcp": [ + { HostIp: "127.0.0.1", HostPort: "" }, + { HostIp: "::1", HostPort: "18080" }, + ], + "9443/tcp": [{ HostIp: "", HostPort: "19443" }], + }; + + const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("startup-command")); + + expect(args).toEqual( + expect.arrayContaining([ + "--publish", + "127.0.0.1::8080/tcp", + "--publish", + "[::1]:18080:8080/tcp", + "--publish", + "19443:9443/tcp", + "--expose", + "9090/udp", + ]), + ); + }); + + it.each([ + { + name: "container port", + port: "65536/tcp", + binding: { HostIp: "127.0.0.1", HostPort: "" }, + error: "Docker container port is invalid.", + }, + { + name: "protocol", + port: "8080/http", + binding: { HostIp: "127.0.0.1", HostPort: "" }, + error: "Docker exposed port is invalid.", + }, + { + name: "host IP address", + port: "8080/tcp", + binding: { HostIp: "localhost", HostPort: "" }, + error: "Docker port binding host IP is invalid.", + }, + { + name: "host port", + port: "8080/tcp", + binding: { HostIp: "127.0.0.1", HostPort: "65536" }, + error: "Docker host port is invalid.", + }, + ])("rejects a Docker port binding with an invalid $name", ({ port, binding, error }) => { + const inspect = inspectFixture(); + inspect.HostConfig!.PortBindings = { [port]: [binding] }; + + expect(() => + buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("startup-command")), + ).toThrow(error); + }); + + it("rejects a Docker port binding without a host binding", () => { + const inspect = inspectFixture(); + inspect.HostConfig!.PortBindings = { "8080/tcp": [] }; + + expect(() => + buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("startup-command")), + ).toThrow("Docker port binding must contain at least one host binding."); + }); + it.each([2048, -1])("preserves the exact Docker PID limit %i", (pidsLimit) => { const inspect = inspectFixture(); inspect.HostConfig!.PidsLimit = pidsLimit; diff --git a/src/lib/onboard/docker-gpu-patch-clone.ts b/src/lib/onboard/docker-gpu-patch-clone.ts index 2adb44f47d9..b40699ad24b 100644 --- a/src/lib/onboard/docker-gpu-patch-clone.ts +++ b/src/lib/onboard/docker-gpu-patch-clone.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isIP } from "node:net"; + import type { DockerContainerInspect, DockerGpuCloneRunOptions, @@ -333,6 +335,60 @@ function dockerNetworkAliases( .filter((alias) => !sameContainerId(alias, containerId)); } +function dockerPort(value: string, label: string): string { + if (!/^[1-9][0-9]{0,4}$/u.test(value) || Number(value) > 65_535) { + throw new Error(`Docker ${label} is invalid.`); + } + return value; +} + +function dockerExposedPort(value: string): string { + const match = /^([1-9][0-9]{0,4})\/(tcp|udp|sctp)$/u.exec(value); + if (!match) throw new Error("Docker exposed port is invalid."); + dockerPort(match[1], "container port"); + return value; +} + +function dockerPublishedPort( + exposedPort: string, + binding: { HostIp?: string; HostPort?: string }, +): string { + const hostIp = String(binding.HostIp ?? "").trim(); + const hostPort = String(binding.HostPort ?? "").trim(); + if (hostIp && isIP(hostIp) === 0) throw new Error("Docker port binding host IP is invalid."); + if (hostPort) dockerPort(hostPort, "host port"); + const renderedHostIp = isIP(hostIp) === 6 ? `[${hostIp}]` : hostIp; + return `${renderedHostIp ? `${renderedHostIp}:` : ""}${hostPort}:${exposedPort}`; +} + +function dockerPortArgs(inspect: DockerContainerInspect): string[] { + const exposedPorts = inspect.Config?.ExposedPorts ?? {}; + const portBindings = inspect.HostConfig?.PortBindings ?? {}; + const args: string[] = []; + const publishedPorts = new Set(); + + for (const exposedPort of Object.keys(portBindings).sort()) { + dockerExposedPort(exposedPort); + const bindings = portBindings[exposedPort]; + if (!Array.isArray(bindings) || bindings.length === 0) { + throw new Error("Docker port binding must contain at least one host binding."); + } + for (const binding of bindings) { + if (!binding || typeof binding !== "object") { + throw new Error("Docker port binding is invalid."); + } + args.push("--publish", dockerPublishedPort(exposedPort, binding)); + } + publishedPorts.add(exposedPort); + } + + for (const exposedPort of Object.keys(exposedPorts).sort()) { + dockerExposedPort(exposedPort); + if (!publishedPorts.has(exposedPort)) args.push("--expose", exposedPort); + } + return args; +} + export function buildDockerGpuCloneRunArgs( inspect: DockerContainerInspect, mode: DockerGpuPatchMode, @@ -406,6 +462,7 @@ export function buildDockerGpuCloneRunArgs( pushStringFlag(args, "--network", networkMode); for (const alias of dockerNetworkAliases(inspect, networkMode)) args.push("--network-alias", alias); + args.push(...dockerPortArgs(inspect)); const restart = host.RestartPolicy; if (restart?.Name && restart.Name !== "no") { diff --git a/src/lib/onboard/docker-gpu-patch-types.ts b/src/lib/onboard/docker-gpu-patch-types.ts index f367df0ab22..2b6ef04d9a7 100644 --- a/src/lib/onboard/docker-gpu-patch-types.ts +++ b/src/lib/onboard/docker-gpu-patch-types.ts @@ -204,6 +204,7 @@ export type DockerContainerInspect = { AttachStdin?: boolean; AttachStdout?: boolean; AttachStderr?: boolean; + ExposedPorts?: Record> | null; Env?: string[] | null; Labels?: Record | null; Entrypoint?: string[] | string | null; @@ -259,6 +260,7 @@ export type DockerContainerInspect = { CpusetCpus?: string; CpusetMems?: string; PidsLimit?: number | null; + PortBindings?: Record | null> | null; ConsoleSize?: number[] | null; Privileged?: boolean; Init?: boolean; diff --git a/src/lib/onboard/managed-bootstrap/docker-spec.test.ts b/src/lib/onboard/managed-bootstrap/docker-spec.test.ts index b7ec02ad475..33d389854bf 100644 --- a/src/lib/onboard/managed-bootstrap/docker-spec.test.ts +++ b/src/lib/onboard/managed-bootstrap/docker-spec.test.ts @@ -78,6 +78,26 @@ describe("managed bootstrap Docker launch spec", () => { expect(observed.hash).not.toBe(expected.hash); }); + it("canonicalizes detached OpenShell streams to Docker CLI create defaults", () => { + const openshellInspect = createDockerGpuInspectFixture(); + Object.assign(openshellInspect.Config!, { + AttachStdin: false, + AttachStdout: false, + AttachStderr: false, + }); + const dockerCliInspect = structuredClone(openshellInspect); + Object.assign(dockerCliInspect.Config!, { + AttachStdout: true, + AttachStderr: true, + }); + + const expected = normalizeDockerManagedBootstrapLaunchSpec(openshellInspect); + const observed = normalizeDockerManagedBootstrapLaunchSpec(dockerCliInspect); + + expect(observed.canonicalJson).toBe(expected.canonicalJson); + expect(observed.hash).toBe(expected.hash); + }); + it("canonicalizes Docker API and CLI host-list representations", () => { const apiInspect = createDockerGpuInspectFixture(); Object.assign(apiInspect.HostConfig!, { @@ -93,6 +113,7 @@ describe("managed bootstrap Docker launch spec", () => { DnsOptions: null, DnsSearch: null, OomKillDisable: null, + PortBindings: null, Ulimits: null, }); const cliInspect = structuredClone(apiInspect); @@ -116,6 +137,7 @@ describe("managed bootstrap Docker launch spec", () => { DnsOptions: [], DnsSearch: [], OomKillDisable: false, + PortBindings: {}, MaskedPaths: ["/sys/firmware", "/proc/kcore"], ReadonlyPaths: ["/proc/sysrq-trigger", "/proc/sys"], Ulimits: [], diff --git a/src/lib/onboard/managed-bootstrap/docker-spec.ts b/src/lib/onboard/managed-bootstrap/docker-spec.ts index 15a9aa978ea..3216b223abc 100644 --- a/src/lib/onboard/managed-bootstrap/docker-spec.ts +++ b/src/lib/onboard/managed-bootstrap/docker-spec.ts @@ -112,9 +112,9 @@ const UNSUPPORTED_CONFIG_KEYS = new Set([ "Volumes", ]); -// Docker exposes each attach stream independently through `--attach`, so the -// clone renderer can preserve these Config booleans exactly. Keep them in the -// normalized launch spec and verify the stopped replacement before cutover. +// Docker exposes active attach streams through `--attach`. Keep each +// representable combination in the launch spec and verify the stopped +// replacement before cutover. `normalizedConfig` handles the all-false tuple. const UNSUPPORTED_HOST_CONFIG_KEYS = new Set([ "BlkioDeviceReadBps", @@ -292,6 +292,18 @@ function normalizedConfig(config: Record): Record): Record Date: Thu, 6 Aug 2026 09:48:46 -0700 Subject: [PATCH 6/6] chore(policy): keep dpkg change scoped Signed-off-by: Apurv Kumaria --- .../onboard/docker-gpu-patch-clone.test.ts | 74 ------------------- src/lib/onboard/docker-gpu-patch-clone.ts | 57 -------------- src/lib/onboard/docker-gpu-patch-types.ts | 2 - .../managed-bootstrap/docker-spec.test.ts | 22 ------ .../onboard/managed-bootstrap/docker-spec.ts | 21 +----- 5 files changed, 3 insertions(+), 173 deletions(-) diff --git a/src/lib/onboard/docker-gpu-patch-clone.test.ts b/src/lib/onboard/docker-gpu-patch-clone.test.ts index b3bbaa0ca95..a6c11ee9f84 100644 --- a/src/lib/onboard/docker-gpu-patch-clone.test.ts +++ b/src/lib/onboard/docker-gpu-patch-clone.test.ts @@ -137,80 +137,6 @@ describe("Docker GPU clone envelope", () => { ); }); - it("preserves explicit Docker port bindings and exposed ports", () => { - const inspect = inspectFixture(); - inspect.Config!.ExposedPorts = { - "8080/tcp": {}, - "9090/udp": {}, - "9443/tcp": {}, - }; - inspect.HostConfig!.PortBindings = { - "8080/tcp": [ - { HostIp: "127.0.0.1", HostPort: "" }, - { HostIp: "::1", HostPort: "18080" }, - ], - "9443/tcp": [{ HostIp: "", HostPort: "19443" }], - }; - - const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("startup-command")); - - expect(args).toEqual( - expect.arrayContaining([ - "--publish", - "127.0.0.1::8080/tcp", - "--publish", - "[::1]:18080:8080/tcp", - "--publish", - "19443:9443/tcp", - "--expose", - "9090/udp", - ]), - ); - }); - - it.each([ - { - name: "container port", - port: "65536/tcp", - binding: { HostIp: "127.0.0.1", HostPort: "" }, - error: "Docker container port is invalid.", - }, - { - name: "protocol", - port: "8080/http", - binding: { HostIp: "127.0.0.1", HostPort: "" }, - error: "Docker exposed port is invalid.", - }, - { - name: "host IP address", - port: "8080/tcp", - binding: { HostIp: "localhost", HostPort: "" }, - error: "Docker port binding host IP is invalid.", - }, - { - name: "host port", - port: "8080/tcp", - binding: { HostIp: "127.0.0.1", HostPort: "65536" }, - error: "Docker host port is invalid.", - }, - ])("rejects a Docker port binding with an invalid $name", ({ port, binding, error }) => { - const inspect = inspectFixture(); - inspect.HostConfig!.PortBindings = { [port]: [binding] }; - - expect(() => - buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("startup-command")), - ).toThrow(error); - }); - - it("rejects a Docker port binding without a host binding", () => { - const inspect = inspectFixture(); - inspect.HostConfig!.PortBindings = { "8080/tcp": [] }; - - expect(() => - buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("startup-command")), - ).toThrow("Docker port binding must contain at least one host binding."); - }); - it.each([2048, -1])("preserves the exact Docker PID limit %i", (pidsLimit) => { const inspect = inspectFixture(); inspect.HostConfig!.PidsLimit = pidsLimit; diff --git a/src/lib/onboard/docker-gpu-patch-clone.ts b/src/lib/onboard/docker-gpu-patch-clone.ts index b40699ad24b..2adb44f47d9 100644 --- a/src/lib/onboard/docker-gpu-patch-clone.ts +++ b/src/lib/onboard/docker-gpu-patch-clone.ts @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isIP } from "node:net"; - import type { DockerContainerInspect, DockerGpuCloneRunOptions, @@ -335,60 +333,6 @@ function dockerNetworkAliases( .filter((alias) => !sameContainerId(alias, containerId)); } -function dockerPort(value: string, label: string): string { - if (!/^[1-9][0-9]{0,4}$/u.test(value) || Number(value) > 65_535) { - throw new Error(`Docker ${label} is invalid.`); - } - return value; -} - -function dockerExposedPort(value: string): string { - const match = /^([1-9][0-9]{0,4})\/(tcp|udp|sctp)$/u.exec(value); - if (!match) throw new Error("Docker exposed port is invalid."); - dockerPort(match[1], "container port"); - return value; -} - -function dockerPublishedPort( - exposedPort: string, - binding: { HostIp?: string; HostPort?: string }, -): string { - const hostIp = String(binding.HostIp ?? "").trim(); - const hostPort = String(binding.HostPort ?? "").trim(); - if (hostIp && isIP(hostIp) === 0) throw new Error("Docker port binding host IP is invalid."); - if (hostPort) dockerPort(hostPort, "host port"); - const renderedHostIp = isIP(hostIp) === 6 ? `[${hostIp}]` : hostIp; - return `${renderedHostIp ? `${renderedHostIp}:` : ""}${hostPort}:${exposedPort}`; -} - -function dockerPortArgs(inspect: DockerContainerInspect): string[] { - const exposedPorts = inspect.Config?.ExposedPorts ?? {}; - const portBindings = inspect.HostConfig?.PortBindings ?? {}; - const args: string[] = []; - const publishedPorts = new Set(); - - for (const exposedPort of Object.keys(portBindings).sort()) { - dockerExposedPort(exposedPort); - const bindings = portBindings[exposedPort]; - if (!Array.isArray(bindings) || bindings.length === 0) { - throw new Error("Docker port binding must contain at least one host binding."); - } - for (const binding of bindings) { - if (!binding || typeof binding !== "object") { - throw new Error("Docker port binding is invalid."); - } - args.push("--publish", dockerPublishedPort(exposedPort, binding)); - } - publishedPorts.add(exposedPort); - } - - for (const exposedPort of Object.keys(exposedPorts).sort()) { - dockerExposedPort(exposedPort); - if (!publishedPorts.has(exposedPort)) args.push("--expose", exposedPort); - } - return args; -} - export function buildDockerGpuCloneRunArgs( inspect: DockerContainerInspect, mode: DockerGpuPatchMode, @@ -462,7 +406,6 @@ export function buildDockerGpuCloneRunArgs( pushStringFlag(args, "--network", networkMode); for (const alias of dockerNetworkAliases(inspect, networkMode)) args.push("--network-alias", alias); - args.push(...dockerPortArgs(inspect)); const restart = host.RestartPolicy; if (restart?.Name && restart.Name !== "no") { diff --git a/src/lib/onboard/docker-gpu-patch-types.ts b/src/lib/onboard/docker-gpu-patch-types.ts index 2b6ef04d9a7..f367df0ab22 100644 --- a/src/lib/onboard/docker-gpu-patch-types.ts +++ b/src/lib/onboard/docker-gpu-patch-types.ts @@ -204,7 +204,6 @@ export type DockerContainerInspect = { AttachStdin?: boolean; AttachStdout?: boolean; AttachStderr?: boolean; - ExposedPorts?: Record> | null; Env?: string[] | null; Labels?: Record | null; Entrypoint?: string[] | string | null; @@ -260,7 +259,6 @@ export type DockerContainerInspect = { CpusetCpus?: string; CpusetMems?: string; PidsLimit?: number | null; - PortBindings?: Record | null> | null; ConsoleSize?: number[] | null; Privileged?: boolean; Init?: boolean; diff --git a/src/lib/onboard/managed-bootstrap/docker-spec.test.ts b/src/lib/onboard/managed-bootstrap/docker-spec.test.ts index 33d389854bf..b7ec02ad475 100644 --- a/src/lib/onboard/managed-bootstrap/docker-spec.test.ts +++ b/src/lib/onboard/managed-bootstrap/docker-spec.test.ts @@ -78,26 +78,6 @@ describe("managed bootstrap Docker launch spec", () => { expect(observed.hash).not.toBe(expected.hash); }); - it("canonicalizes detached OpenShell streams to Docker CLI create defaults", () => { - const openshellInspect = createDockerGpuInspectFixture(); - Object.assign(openshellInspect.Config!, { - AttachStdin: false, - AttachStdout: false, - AttachStderr: false, - }); - const dockerCliInspect = structuredClone(openshellInspect); - Object.assign(dockerCliInspect.Config!, { - AttachStdout: true, - AttachStderr: true, - }); - - const expected = normalizeDockerManagedBootstrapLaunchSpec(openshellInspect); - const observed = normalizeDockerManagedBootstrapLaunchSpec(dockerCliInspect); - - expect(observed.canonicalJson).toBe(expected.canonicalJson); - expect(observed.hash).toBe(expected.hash); - }); - it("canonicalizes Docker API and CLI host-list representations", () => { const apiInspect = createDockerGpuInspectFixture(); Object.assign(apiInspect.HostConfig!, { @@ -113,7 +93,6 @@ describe("managed bootstrap Docker launch spec", () => { DnsOptions: null, DnsSearch: null, OomKillDisable: null, - PortBindings: null, Ulimits: null, }); const cliInspect = structuredClone(apiInspect); @@ -137,7 +116,6 @@ describe("managed bootstrap Docker launch spec", () => { DnsOptions: [], DnsSearch: [], OomKillDisable: false, - PortBindings: {}, MaskedPaths: ["/sys/firmware", "/proc/kcore"], ReadonlyPaths: ["/proc/sysrq-trigger", "/proc/sys"], Ulimits: [], diff --git a/src/lib/onboard/managed-bootstrap/docker-spec.ts b/src/lib/onboard/managed-bootstrap/docker-spec.ts index 3216b223abc..15a9aa978ea 100644 --- a/src/lib/onboard/managed-bootstrap/docker-spec.ts +++ b/src/lib/onboard/managed-bootstrap/docker-spec.ts @@ -112,9 +112,9 @@ const UNSUPPORTED_CONFIG_KEYS = new Set([ "Volumes", ]); -// Docker exposes active attach streams through `--attach`. Keep each -// representable combination in the launch spec and verify the stopped -// replacement before cutover. `normalizedConfig` handles the all-false tuple. +// Docker exposes each attach stream independently through `--attach`, so the +// clone renderer can preserve these Config booleans exactly. Keep them in the +// normalized launch spec and verify the stopped replacement before cutover. const UNSUPPORTED_HOST_CONFIG_KEYS = new Set([ "BlkioDeviceReadBps", @@ -292,18 +292,6 @@ function normalizedConfig(config: Record): Record): Record