Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions agents/hermes/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ filesystem_policy:
- /app
- /etc
- /var/log
- /var/lib/dpkg # Allow package-version inspection without package mutation.
read_write:
- /sandbox
- /tmp
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ filesystem_policy:
- /app
- /etc
- /var/log
- /var/lib/dpkg # Allow package-version inspection without package mutation.
read_write:
- /sandbox
- /tmp
Expand Down
1 change: 1 addition & 0 deletions agents/langchain-deepagents-code/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions agents/openclaw/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/network-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<AgentOnly variant="openclaw">
Landlock LSM enforcement applies on a best-effort basis.
Expand Down
2 changes: 1 addition & 1 deletion docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions nemoclaw-blueprint/policies/openclaw-sandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion src/lib/onboard/initial-policy-real-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type PolicyEntry = {
};

type PolicyDocument = {
filesystem_policy?: { read_write?: string[] };
filesystem_policy?: { read_only?: string[]; read_write?: string[] };
network_policies?: Record<string, PolicyEntry>;
};

Expand Down Expand Up @@ -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);
}
}
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
it("preserves baseline writable paths in effective OpenClaw permissive create policies", () => {
const baseline = readPreparedPolicy(
prepareInitialSandboxCreatePolicy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion test/e2e/live/hermes-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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/"],
Expand Down
17 changes: 15 additions & 2 deletions test/e2e/live/network-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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_/,
Expand Down
95 changes: 95 additions & 0 deletions test/e2e/live/package-database-read-only.ts
Original file line number Diff line number Diff line change
@@ -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<ShellProbeResult, "stdout" | "stderr">): 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<void> {
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");
}
}
Loading