Skip to content

fix(security): isolate tool subprocesses with PID namespaces#4432

Closed
raktes wants to merge 4 commits into
NousResearch:mainfrom
raktes:fix/pidns-subprocess-isolation
Closed

fix(security): isolate tool subprocesses with PID namespaces#4432
raktes wants to merge 4 commits into
NousResearch:mainfrom
raktes:fix/pidns-subprocess-isolation

Conversation

@raktes

@raktes raktes commented Apr 1, 2026

Copy link
Copy Markdown

What does this PR do?

The env blocklist in _sanitize_subprocess_env() and the _SECRET_SUBSTRINGS filter in code_execution_tool can be bypassed on Linux: any same-UID child process can read /proc/<parent_pid>/environ and recover every stripped variable.

This PR wraps short-lived tool subprocesses in a PID namespace via unshare --user --pid --fork --mount-proc. Inside the namespace, /proc is remounted so the parent's PID does not exist — the child cannot address or scan for the parent's environ.

Falls back to the current (unwrapped) behavior on non-Linux platforms and when unshare is unavailable (e.g. restricted containers).

Related Issue

Partially addresses #4427

Scope

PID namespace isolation is applied to:

  • execute_code — sandboxed Python script spawn
  • Short-lived foreground local terminal commands — one-shot subprocess.Popen path

Not applied to persistent local shells, terminal(background=true), or terminal(pty=true), because their timeout/interrupt cleanup depends on host-visible PIDs ($$ plus pkill -P). Inside a PID namespace that PID becomes namespace-local, which makes child reaping unreliable.

Known limitations

  • Filesystem secrets are not protected. Child processes share the parent's filesystem and can read ~/.hermes/.env and ~/.hermes/auth.json directly. Running Hermes in a container with secrets provided exclusively through environment variables provides the strongest isolation.
  • Persistent local shells remain in the host PID namespace (see above).

Type of Change

  • 🔒 Security fix
  • ✅ Tests (adding or improving test coverage)

Changes Made

  • tools/environments/local.py: Added _have_unshare() runtime probe and _pidns_wrap() helper that prefixes commands with unshare --user --pid --fork --mount-proc on Linux. Applied to the one-shot foreground command spawn path.
  • tools/code_execution_tool.py: Imports _pidns_wrap from local.py and applies it to the sandboxed Python script spawn.
  • tests/tools/test_local_env_blocklist.py: Added unit tests for _pidns_wrap (wrap/no-op behavior), _have_unshare probe, persistent-shell-not-wrapped assertion, and a regression integration test that spawns a real child in a PID namespace and verifies it cannot recover stripped env vars via /proc.
  • website/docs/user-guide/security.md: Added limitation warning documenting filesystem secret exposure and persistent-shell gap.

How to Test

  1. Run unit tests: pytest tests/tools/test_local_env_blocklist.py::TestPidNamespaceWrap -v
  2. Run integration test (Linux with unshare available): pytest tests/tools/test_local_env_blocklist.py::TestProcEnvironIsolation -v
  3. Integration test is skipped on macOS/Windows and when unshare is unavailable

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q — some pre-existing test failures unrelated to this PR (same failures reproduced on origin/main)
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Docker (official Hermes image, Debian 13, Python 3.13)

Documentation & Housekeeping

  • I've updated relevant documentation (security.md, code-execution.md, contributing.md)
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

raktes added 3 commits April 1, 2026 12:20
The env blocklist in _sanitize_subprocess_env() and the
_SECRET_SUBSTRINGS filter in code_execution_tool can be bypassed on
Linux: any same-UID child process can read /proc/<parent_pid>/environ
and recover every stripped variable.

Wrap tool subprocesses in a PID namespace via
`unshare --user --pid --fork --mount-proc`. Inside the namespace,
/proc is remounted so the parent's PID does not exist — the child
cannot address or scan for the parent's environ.

Falls back to the current (unwrapped) behavior on non-Linux platforms
and when unshare is unavailable (e.g. restricted containers).

Fixes NousResearch#4427
@jeremyjh

jeremyjh commented Apr 1, 2026

Copy link
Copy Markdown

@raktes @teknium1 this is a good change. I think with this change, if the user runs all of hermes itself in docker and secrets are only passed through environment variables, then there is no obvious way to use terminal or execute_code to exfiltrate secrets. Any other config is still vulnerable because the child process still has access to the agent's file system. It can't use the exact vector I demonstrated, but another vector that still works is a trivial exercise I will leave to the reader. I would like to see that acknowledged in the security notes.

Remove PID namespace wrapping from persistent local shells — their
timeout/interrupt cleanup depends on host-visible PIDs for child
reaping. Keep isolation on execute_code and short-lived foreground
local terminal commands only.

Replace the shutil.which check with a real unshare probe so systems
where user namespaces are disabled fall back cleanly instead of
failing hard.

Add limitation warning to security docs: filesystem secrets and
persistent/background/PTY shells remain unprotected.
@raktes

raktes commented Apr 1, 2026

Copy link
Copy Markdown
Author

@jeremyjh 👍 Added an explicit limitation warning in the security docs covering the filesystem gap. Also had to narrow down the scope: PID namespace isolation now only applies to execute_code and short-lived foreground terminal commands — persistent shells are excluded due to PID-dependent child reaping (see PR description).

@raktes raktes marked this pull request as ready for review April 1, 2026 17:06
dsr-restyn added a commit to dsr-restyn/hermes-agent that referenced this pull request Apr 3, 2026
The read_file tool and terminal cat can access /proc/self/environ to
recover all process env vars including secrets stripped by the subprocess
blocklist. Output redaction partially mitigates (catches known-format
tokens) but misses custom/proprietary key formats, especially when
values are printed without their key names.

Add /proc/*/environ, /proc/*/cmdline, and /proc/*/maps to the blocked
device paths in _is_blocked_device():

- /proc/*/environ: leaks full process env (API keys, tokens)
- /proc/*/cmdline: leaks command-line args (may contain passwords)
- /proc/*/maps: leaks memory layout (ASLR bypass for exploitation)

Legitimate /proc reads (cpuinfo, meminfo, uptime, version) remain
accessible — the check only blocks per-pid pseudo-files with known
sensitive suffixes.

Complements PR NousResearch#4432 (PID namespace isolation for child processes)
which prevents children from reading the parent's /proc, but does not
prevent the parent process itself from being read via file tools.

Partially addresses NousResearch#4427

Changes:
  tools/file_tools.py                  | +6
  tests/tools/test_file_read_guards.py | +18 -1
@teknium1

teknium1 commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Verified the /proc/environ bypass — it's real. A child process with stripped env can recover secrets from the parent:

# Parent has FAKE_API_KEY in its exec-time env
# Child spawned with stripped env reads /proc/<ppid>/environ
>>> output: sk-super-secret-key-12345  # leaked

However, the same child can also just do this:

open(os.path.expanduser('~/.hermes/.env')).read()
# OPENROUTER_API_KEY=sk-or-...
# FIRECRAWL_API_KEY=...
# NOUS_API_KEY=...

The filesystem path is wider, easier, and unaffected by this fix. The PR itself acknowledges this limitation. Adding unshare --user --pid --fork --mount-proc to every foreground command introduces Linux-only behavior divergence, kernel feature dependencies (user namespaces must be enabled), and complexity around persistent shell exclusions — all for an incomplete mitigation that leaves the bigger hole untouched.

The env var stripping in _sanitize_subprocess_env() is defense-in-depth, not a security boundary. The actual security boundary for untrusted code execution is the Docker/container backend, where both /proc and the filesystem are properly isolated. This is already the documented recommendation.

Appreciate the thorough security analysis and the clean implementation — the PR itself is well done. But we'd rather not add this complexity for a partial fix. If we revisit local backend hardening in the future, it would need to address filesystem access too (likely via mount namespaces or seccomp), and at that point we'd be reimplementing what Docker already does.

@teknium1 teknium1 closed this Apr 4, 2026
@jeremyjh

jeremyjh commented Apr 4, 2026

Copy link
Copy Markdown

@teknium1 execute_code doesn't use docker, but it calls itself a sandbox. The real issue is that even if you run all of hermes in docker, there are still secrets in the environment variables. So execute_code will always have access to them, without a fix like this one. There is no security barrier.

dsr-restyn added a commit to dsr-restyn/hermes-agent that referenced this pull request Apr 14, 2026
The read_file tool and terminal cat can access /proc/self/environ to
recover all process env vars including secrets stripped by the subprocess
blocklist. Output redaction partially mitigates (catches known-format
tokens) but misses custom/proprietary key formats, especially when
values are printed without their key names.

Add /proc/*/environ, /proc/*/cmdline, and /proc/*/maps to the blocked
device paths in _is_blocked_device():

- /proc/*/environ: leaks full process env (API keys, tokens)
- /proc/*/cmdline: leaks command-line args (may contain passwords)
- /proc/*/maps: leaks memory layout (ASLR bypass for exploitation)

Legitimate /proc reads (cpuinfo, meminfo, uptime, version) remain
accessible — the check only blocks per-pid pseudo-files with known
sensitive suffixes.

Complements PR NousResearch#4432 (PID namespace isolation for child processes)
which prevents children from reading the parent's /proc, but does not
prevent the parent process itself from being read via file tools.

Partially addresses NousResearch#4427

Changes:
  tools/file_tools.py                  | +6
  tests/tools/test_file_read_guards.py | +18 -1
teknium1 pushed a commit that referenced this pull request May 25, 2026
…4609)

The read_file tool and terminal cat can access /proc/self/environ to
recover all process env vars including secrets stripped by the subprocess
blocklist. Output redaction partially mitigates (catches known-format
tokens) but misses custom/proprietary key formats, especially when
values are printed without their key names.

Add /proc/*/environ, /proc/*/cmdline, and /proc/*/maps to the blocked
device paths in _is_blocked_device():

- /proc/*/environ: leaks full process env (API keys, tokens)
- /proc/*/cmdline: leaks command-line args (may contain passwords)
- /proc/*/maps: leaks memory layout (ASLR bypass for exploitation)

Legitimate /proc reads (cpuinfo, meminfo, uptime, version) remain
accessible — the check only blocks per-pid pseudo-files with known
sensitive suffixes.

Complements PR #4432 (PID namespace isolation for child processes)
which prevents children from reading the parent's /proc, but does not
prevent the parent process itself from being read via file tools.

Partially addresses #4427

Changes:
  tools/file_tools.py                  | +6
  tests/tools/test_file_read_guards.py | +18 -1

Co-authored-by: dsr-restyn <dsr-restyn@users.noreply.github.com>
daletkc pushed a commit to daletkc/hermes-agent that referenced this pull request May 25, 2026
…ousResearch#4609)

The read_file tool and terminal cat can access /proc/self/environ to
recover all process env vars including secrets stripped by the subprocess
blocklist. Output redaction partially mitigates (catches known-format
tokens) but misses custom/proprietary key formats, especially when
values are printed without their key names.

Add /proc/*/environ, /proc/*/cmdline, and /proc/*/maps to the blocked
device paths in _is_blocked_device():

- /proc/*/environ: leaks full process env (API keys, tokens)
- /proc/*/cmdline: leaks command-line args (may contain passwords)
- /proc/*/maps: leaks memory layout (ASLR bypass for exploitation)

Legitimate /proc reads (cpuinfo, meminfo, uptime, version) remain
accessible — the check only blocks per-pid pseudo-files with known
sensitive suffixes.

Complements PR NousResearch#4432 (PID namespace isolation for child processes)
which prevents children from reading the parent's /proc, but does not
prevent the parent process itself from being read via file tools.

Partially addresses NousResearch#4427

Changes:
  tools/file_tools.py                  | +6
  tests/tools/test_file_read_guards.py | +18 -1

Co-authored-by: dsr-restyn <dsr-restyn@users.noreply.github.com>
mathias3 pushed a commit to mathias3/hermes-agent that referenced this pull request May 28, 2026
…ousResearch#4609)

The read_file tool and terminal cat can access /proc/self/environ to
recover all process env vars including secrets stripped by the subprocess
blocklist. Output redaction partially mitigates (catches known-format
tokens) but misses custom/proprietary key formats, especially when
values are printed without their key names.

Add /proc/*/environ, /proc/*/cmdline, and /proc/*/maps to the blocked
device paths in _is_blocked_device():

- /proc/*/environ: leaks full process env (API keys, tokens)
- /proc/*/cmdline: leaks command-line args (may contain passwords)
- /proc/*/maps: leaks memory layout (ASLR bypass for exploitation)

Legitimate /proc reads (cpuinfo, meminfo, uptime, version) remain
accessible — the check only blocks per-pid pseudo-files with known
sensitive suffixes.

Complements PR NousResearch#4432 (PID namespace isolation for child processes)
which prevents children from reading the parent's /proc, but does not
prevent the parent process itself from being read via file tools.

Partially addresses NousResearch#4427

Changes:
  tools/file_tools.py                  | +6
  tests/tools/test_file_read_guards.py | +18 -1

Co-authored-by: dsr-restyn <dsr-restyn@users.noreply.github.com>
Bryce-huang pushed a commit to wbkunlun/hermes-agent that referenced this pull request May 29, 2026
…ousResearch#4609)

The read_file tool and terminal cat can access /proc/self/environ to
recover all process env vars including secrets stripped by the subprocess
blocklist. Output redaction partially mitigates (catches known-format
tokens) but misses custom/proprietary key formats, especially when
values are printed without their key names.

Add /proc/*/environ, /proc/*/cmdline, and /proc/*/maps to the blocked
device paths in _is_blocked_device():

- /proc/*/environ: leaks full process env (API keys, tokens)
- /proc/*/cmdline: leaks command-line args (may contain passwords)
- /proc/*/maps: leaks memory layout (ASLR bypass for exploitation)

Legitimate /proc reads (cpuinfo, meminfo, uptime, version) remain
accessible — the check only blocks per-pid pseudo-files with known
sensitive suffixes.

Complements PR NousResearch#4432 (PID namespace isolation for child processes)
which prevents children from reading the parent's /proc, but does not
prevent the parent process itself from being read via file tools.

Partially addresses NousResearch#4427

Changes:
  tools/file_tools.py                  | +6
  tests/tools/test_file_read_guards.py | +18 -1

Co-authored-by: dsr-restyn <dsr-restyn@users.noreply.github.com>
#AI commit#
mosaiq-systems pushed a commit to mosaiq-systems/hermes-agent that referenced this pull request May 29, 2026
…ousResearch#4609)

The read_file tool and terminal cat can access /proc/self/environ to
recover all process env vars including secrets stripped by the subprocess
blocklist. Output redaction partially mitigates (catches known-format
tokens) but misses custom/proprietary key formats, especially when
values are printed without their key names.

Add /proc/*/environ, /proc/*/cmdline, and /proc/*/maps to the blocked
device paths in _is_blocked_device():

- /proc/*/environ: leaks full process env (API keys, tokens)
- /proc/*/cmdline: leaks command-line args (may contain passwords)
- /proc/*/maps: leaks memory layout (ASLR bypass for exploitation)

Legitimate /proc reads (cpuinfo, meminfo, uptime, version) remain
accessible — the check only blocks per-pid pseudo-files with known
sensitive suffixes.

Complements PR NousResearch#4432 (PID namespace isolation for child processes)
which prevents children from reading the parent's /proc, but does not
prevent the parent process itself from being read via file tools.

Partially addresses NousResearch#4427

Changes:
  tools/file_tools.py                  | +6
  tests/tools/test_file_read_guards.py | +18 -1

Co-authored-by: dsr-restyn <dsr-restyn@users.noreply.github.com>
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…ousResearch#4609)

The read_file tool and terminal cat can access /proc/self/environ to
recover all process env vars including secrets stripped by the subprocess
blocklist. Output redaction partially mitigates (catches known-format
tokens) but misses custom/proprietary key formats, especially when
values are printed without their key names.

Add /proc/*/environ, /proc/*/cmdline, and /proc/*/maps to the blocked
device paths in _is_blocked_device():

- /proc/*/environ: leaks full process env (API keys, tokens)
- /proc/*/cmdline: leaks command-line args (may contain passwords)
- /proc/*/maps: leaks memory layout (ASLR bypass for exploitation)

Legitimate /proc reads (cpuinfo, meminfo, uptime, version) remain
accessible — the check only blocks per-pid pseudo-files with known
sensitive suffixes.

Complements PR NousResearch#4432 (PID namespace isolation for child processes)
which prevents children from reading the parent's /proc, but does not
prevent the parent process itself from being read via file tools.

Partially addresses NousResearch#4427

Changes:
  tools/file_tools.py                  | +6
  tests/tools/test_file_read_guards.py | +18 -1

Co-authored-by: dsr-restyn <dsr-restyn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants