diff --git a/agents/openclaw/policy-permissive.yaml b/agents/openclaw/policy-permissive.yaml index 6254c4855e..19e6b19374 100644 --- a/agents/openclaw/policy-permissive.yaml +++ b/agents/openclaw/policy-permissive.yaml @@ -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 diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 957c786c77..d92916384d 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -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. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index dabd5cde9f..f5e3da709e 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -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`. | diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml index 9d81965372..a85ab6d8c6 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml @@ -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 diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index 7b95dc7f63..0c907130b5 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -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/ 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) diff --git a/test/e2e/test-sandbox-operations.sh b/test/e2e/test-sandbox-operations.sh index 79a3bb2169..0d702a21d6 100755 --- a/test/e2e/test-sandbox-operations.sh +++ b/test/e2e/test-sandbox-operations.sh @@ -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 @@ -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/ 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-$$" @@ -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 diff --git a/test/runner.test.ts b/test/runner.test.ts index 0dd8aeb638..b833411465 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -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"; @@ -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/ 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"/); + }); }); });