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
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
90 changes: 90 additions & 0 deletions test/e2e/live/package-database-read-only.ts
Original file line number Diff line number Diff line change
@@ -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<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