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: 4 additions & 0 deletions agents/openclaw/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ filesystem_policy:
read_write:
- /tmp
- /dev/null
- /dev/pts # devpts PTY access for the tmux-session flow
# (#4513). Mirrors the baseline read_write so
# `shields down` does not remove a path
# (OpenShell rejects filesystem removals).
- /sandbox/.openclaw
- /sandbox/.nemoclaw
- /home/linuxbrew
Expand Down
6 changes: 5 additions & 1 deletion docs/reference/network-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ Hermes sandboxes use an agent-specific baseline policy in `agents/hermes/policy-

| Path | Access |
|---|---|
| `/sandbox`, `/tmp`, `/dev/null` | Read-write |
| `/sandbox`, `/tmp`, `/dev/null`, `/dev/pts` | Read-write |
| `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log` | 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`.

The sandbox process runs as a dedicated `sandbox` user and group.
Landlock LSM enforcement applies on a best-effort basis.

Expand Down
4 changes: 2 additions & 2 deletions docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,11 @@ Messaging sessions such as WhatsApp pairing can remain mutable by design so they

### Writable Paths

The agent has read-write access to `/sandbox`, `/tmp`, and `/dev/null`.
The agent has read-write access to `/sandbox`, `/tmp`, `/dev/null`, and `/dev/pts`.

| Aspect | Detail |
|---|---|
| Default | `/sandbox` (agent workspace), `/tmp` (temporary files), `/dev/null`. |
| Default | `/sandbox` (agent workspace), `/tmp` (temporary files), `/dev/null`, and `/dev/pts` (the devpts pseudo-terminal directory, required so PTY-based tools such as `tmux`, `script`, and interactive shells can allocate a terminal). |
| What you can change | Add additional writable paths in `filesystem_policy.read_write`. |
| Risk if relaxed | Each additional writable path expands the agent's ability to persist data and potentially modify system behavior. Adding `/var` lets the agent write to log directories. Adding `/home` gives access to other user directories. |
| Recommendation | Keep writable paths to `/sandbox` and `/tmp`. If the agent needs a persistent working directory, create a subdirectory under `/sandbox`. |
Expand Down
4 changes: 4 additions & 0 deletions nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ filesystem_policy:
read_write:
- /tmp
- /dev/null
- /dev/pts # devpts PTY access for the tmux-session flow
# (#4513). Must mirror the default policy —
# OpenShell rejects filesystem path removals
# when `shields down` swaps this policy in.
- /sandbox/.openclaw
- /sandbox/.nemoclaw
- /home/linuxbrew
Expand Down
11 changes: 11 additions & 0 deletions nemoclaw-blueprint/policies/openclaw-sandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ filesystem_policy:
read_write:
- /tmp
- /dev/null
- /dev/pts # PTY multiplexer + slave devices (devpts).
# OpenClaw's bundled tmux-session flow — and
# any PTY allocation (tmux, script, expect,
# interactive shells) — opens /dev/ptmx
# (-> /dev/pts/ptmx) and a /dev/pts/<n> slave
# via forkpty(). Without this grant landlock
# denies the open with EACCES, which tmux
# surfaces as `create window failed: fork
# failed: Permission denied` (#4513). Grant
# the directory, not /dev/ptmx itself — the
# supervisor refuses to chown that symlink.
- /sandbox/.openclaw # Agent config (lockable via shields up)
- /sandbox/.nemoclaw # Plugin state and config (state.ts, config.ts).
# Blueprints are root-owned; sticky bit (1755)
Expand Down
48 changes: 35 additions & 13 deletions test/e2e/test-sandbox-operations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -454,10 +454,15 @@ test_sbx_04_log_streaming() {

# ── TC-SBX-09: Tmux Session Flow ────────────────────────────────────────────
# OpenClaw's bundled tmux-session flow shells out to `tmux` inside the sandbox.
# The sandbox image must ship tmux (issue #4513) or that flow fails with
# `tmux: command not found`. Assert the binary is present and can drive a full
# detached session lifecycle (new-session → list → kill), which is the exact
# shape the bundled flow exercises.
# The sandbox image must ship tmux (issue #4513) AND the sandbox landlock policy
# must grant the devpts PTY devices so tmux can actually allocate a window.
#
# History: #4606 installed tmux but the lifecycle drive then failed with
# `create window failed: fork failed: Permission denied`; #4640 degraded that
# branch to a soft skip. The real cause was landlock denying /dev/ptmx +
# /dev/pts (EACCES on forkpty()), not a fork/seccomp/nproc limit. The base
# policy now grants /dev/pts, so this drive MUST pass — a `fork failed` here is
# a hard regression of #4513, never a skip.
test_sbx_09_tmux_session_flow() {
log "=== TC-SBX-09: Tmux Session Flow ==="
require_sandbox "$SANDBOX_A" "TC-SBX-09" || return
Expand All @@ -470,6 +475,29 @@ test_sbx_09_tmux_session_flow() {
fi
pass "TC-SBX-09: tmux is installed in the sandbox ($(echo "$which_out" | head -1))"

# Pin the #4513 root cause directly: PTY allocation must succeed. This opens
# /dev/ptmx and a /dev/pts/<n> slave the same way tmux's forkpty() does, and
# fails fast with the underlying EACCES if the devpts grant ever regresses.
# Guarded on python3 (a diagnostic) so a missing interpreter cannot be
# mistaken for a devpts regression — the tmux lifecycle below is the gate.
# The PTY_OK sentinel is emitted by a shell `&& echo` only after openpty()
# exits 0 — it is deliberately NOT a literal inside the python source, because
# a Python traceback echoes the failing source line and would otherwise make
# `grep PTY_OK` match on the very EACCES regression this probe guards against.
# PY3_MISSING is gated solely on `command -v`, so an openpty() failure with
# python3 present falls through to `fail`, never to the soft skip.
local pty_out
pty_out=$(sandbox_exec "if command -v python3 >/dev/null 2>&1; then \
python3 -c 'import os; _,s=os.openpty(); print(os.ttyname(s))' && echo PTY_OK; \
else echo PY3_MISSING; fi" 2>&1) || true
if echo "$pty_out" | grep -q "PTY_OK"; then
pass "TC-SBX-09: PTY allocation works (slave $(echo "$pty_out" | grep -E '^/dev/pts/' | head -1))"
elif echo "$pty_out" | grep -q "PY3_MISSING"; then
log "TC-SBX-09: python3 unavailable; skipping direct openpty() probe (tmux lifecycle still gates devpts)"
else
fail "TC-SBX-09: PTY allocation" "openpty() failed — devpts not granted by sandbox policy (#4513): $(echo "$pty_out" | head -3)"
fi

# Drive a detached session lifecycle the way the bundled flow does. tmux needs
# a writable socket dir; /tmp is on the sandbox write set.
local sess="nemoclaw-e2e-tmux-$$"
Expand All @@ -481,16 +509,10 @@ test_sbx_09_tmux_session_flow() {

if echo "$flow_out" | grep -q "TMUX_FLOW_OK" && echo "$flow_out" | grep -q "${sess}"; then
pass "TC-SBX-09: tmux new/list/kill session lifecycle works"
elif echo "$flow_out" | grep -qE "fork failed: (Permission denied|Resource temporarily unavailable|Operation not permitted)"; then
# Sandbox hardening (seccomp + no-new-privileges + nproc cap) can refuse
# tmux's fork-to-spawn child window under the e2e SSH session account.
# The binary-presence assertion above already covers the install surface;
# the lifecycle drive depends on runtime capabilities that are
# environment-dependent and not in scope of this case.
sandbox_exec "TMUX_TMPDIR=/tmp tmux kill-session -t '${sess}' 2>/dev/null || true" >/dev/null 2>&1 || true
skip "TC-SBX-09" "tmux lifecycle drive blocked by sandbox fork policy: $(echo "$flow_out" | head -3)"
else
# Best-effort cleanup in case kill-session never ran.
# Best-effort cleanup in case kill-session never ran. A `fork failed`
# message here means the devpts grant regressed — fail loudly (#4513),
# do not skip.
sandbox_exec "TMUX_TMPDIR=/tmp tmux kill-session -t '${sess}' 2>/dev/null || true" >/dev/null 2>&1 || true
fail "TC-SBX-09: Tmux Session Flow" "Session lifecycle failed: $(echo "$flow_out" | head -5)"
fi
Expand Down
38 changes: 38 additions & 0 deletions test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import YAML from "yaml";

import { redact, runCapture } from "../dist/lib/runner";

Expand Down Expand Up @@ -916,5 +917,42 @@ describe("regression guards", () => {
// The smoke must be wired into the run, not just defined.
expect(src).toMatch(/^\s*test_sbx_09_tmux_session_flow\s*$/m);
});

// The reopened #4513: installing tmux was not enough — the bundled
// tmux-session flow still failed with `create window failed: fork failed:
// Permission denied`. Root cause: the sandbox landlock filesystem policy
// never granted the devpts PTY devices, so forkpty() open of /dev/ptmx
// (-> /dev/pts/ptmx) and the /dev/pts/<n> slave was denied with EACCES.
for (const policyFile of [
path.join("nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"),
path.join("nemoclaw-blueprint", "policies", "openclaw-sandbox-permissive.yaml"),
path.join("agents", "openclaw", "policy-permissive.yaml"),
]) {
it(`${policyFile} grants /dev/pts so PTY allocation (tmux) works`, () => {
const doc = YAML.parse(fs.readFileSync(path.join(repoRoot, policyFile), "utf-8"));
const readWrite: string[] = doc.filesystem_policy?.read_write ?? [];
// devpts must be writable — tmux opens the master and slave O_RDWR.
expect(readWrite).toContain("/dev/pts");
// /dev/ptmx is a symlink to pts/ptmx; the supervisor refuses to chown a
// symlinked read_write path, so it must NOT be listed directly. The
// /dev/pts directory grant already covers ptmx via the landlock
// path hierarchy.
expect(readWrite).not.toContain("/dev/ptmx");
});
}

it("e2e TC-SBX-09 hard-asserts the tmux lifecycle and no longer skips on fork failure", () => {
const src = fs.readFileSync(
path.join(repoRoot, "test", "e2e", "test-sandbox-operations.sh"),
"utf-8",
);
// The PTY root cause is pinned with an explicit openpty() probe.
expect(src).toContain("os.openpty()");
// The #4640 soft-skip-on-fork-failure branch must be gone — a fork
// failure now means the devpts grant regressed and must fail loudly.
const tc09 = src.slice(src.indexOf("test_sbx_09_tmux_session_flow"));
const tc09Body = tc09.slice(0, tc09.indexOf("\n}\n"));
expect(tc09Body).not.toMatch(/skip "TC-SBX-09"/);
});
});
});
Loading