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( 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 894b3a7b731..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, @@ -509,7 +510,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 +525,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 +647,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 +668,15 @@ 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({ + 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( /STATUS_403|ERROR_/, 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..16a403abed0 --- /dev/null +++ b/test/e2e/live/package-database-read-only.ts @@ -0,0 +1,90 @@ +// 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 { resultText, shellQuote } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; + +type PackageDatabaseProbeOptions = { + artifactPrefix: string; + env: NodeJS.ProcessEnv; + host: HostCliClient; + sandbox: SandboxClient; + sandboxName: string; + timeoutMs: number; +}; + +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"); + } +}