glob.os.system: The Module Nobody Blocked, and the Sandbox Rewrite That Followed
CVE-2026-55376 was published to NVD on 13 October 2026 at 16:17:21Z by the GitHub Security Advisories CNA, scored 8.8 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) with CWE-693, CWE-94 and CWE-78. The affected product is Heym, a self-hosted platform whose own description is “Build agentic systems. Run them with confidence. Orchestrate agents, automate business processes, inspect every execution, and keep humans in control.” Prior to 0.0.40, an authenticated user who could attach a custom Python tool to an agent escaped the tool sandbox and ran OS commands as the backend application user.
The record is interesting for two reasons, and neither is the CVE number. The first is the escape itself, which is about as small as an escape gets. The second is that the fix shipped 123 days before the CVE was published, and when we read it, it is the rarest thing in this beat: a maintainer who did not patch the bypass.
What the sandbox actually blocked
We read backend/app/services/python_tool_executor.py at the pre-fix parent commit df392da7. Nobody should characterise this as lazy work. The file opens with a BLACKLIST_BUILTINS frozenset of 42 names — exec, eval, compile, __import__, open, getattr, setattr, globals, locals, vars, dir, super, object, type, __build_class__ and the rest — a BLACKLIST_MODULES set of 36 including os, sys, subprocess, socket, ctypes, pickle, importlib, inspect, ast, pty, multiprocessing, and a FORBIDDEN_CODE_FRAGMENTS set targeting the classic object-graph escapes: catch_warnings, func_globals, f_globals, gi_frame, cr_frame, tb_frame, and the blunt instrument "__".
The enforcement is layered. _validate_code_safety() rejects the source outright if any forbidden fragment appears anywhere in it, then parses it with ast and walks every node: any ast.Attribute whose attr starts with an underscore raises, any ast.Name in the builtin denylist raises, and every string constant is re-checked for the forbidden fragments so you cannot smuggle "__class__" in as data. _safe_import() replaces __import__ and refuses any module whose root name is on the module denylist. _create_safe_builtins() rebuilds the namespace from vars(builtins) minus the denied names. The resulting runner script is written to a temp file and executed as a separate process with a scrubbed environment (PATH, PYTHONIOENCODING, PYTHONSAFEPATH only), in a throwaway working directory, under a timeout.
Now the bypass, as the reporting researcher mbakgun wrote it in the advisory:
def run():
import glob
return glob.os.system('id > /tmp/heym_security_repro_marker')
That is the whole thing. glob is not on the module denylist, because glob is not dangerous. glob does import os at module scope, so the module object carries a public attribute named os that is the real os module. .os does not start with an underscore, so the AST attribute check passes. .system does not start with an underscore, so that passes too. There is no __ anywhere in the payload, so the fragment scan passes. getattr is denied, but the attacker never needs it — plain attribute access is not dynamic. Four separate defences, each correctly implemented, and the payload satisfies all four.
The advisory lists further verified vectors of the same shape: shutil.os.popen(...).read() for execution with output capture, asyncio.create_subprocess_shell(...), and pathlib.Path(...).read_text() / .write_text(...) for arbitrary file read and write. We confirmed the re-export surface locally on CPython 3.14.7 by attribute inspection only — glob.os.system, shutil.os.system, pathlib.os.system and zipfile.os.system all resolve — without executing any payload and without installing Heym. The class is not a bug in Python; it is what a module object is.
On reachability, the advisory is careful in a way worth repeating. The baseline claim is authenticated RCE. But backend/app/config.py line 52 reads allow_register: bool = True, so on a default-configured instance anyone who can reach it can self-register and then exploit it — while docker-compose.yml defaults ALLOW_REGISTER to false when the variable is unset. And the researcher explicitly declined to escalate further: “Public portal routes can trigger exposed owner-created workflows, but trigger-only behavior is not the same as unauthenticated code control.” That sentence survived into the NVD description almost intact, which is unusual and good.
The response, which is the actual story
Reconstructed from the repository: v0.0.39 shipped 10 June 2026. The fix commit b36d795c landed 11 June 2026 at 06:39:54Z. v0.0.40 shipped 12 June 2026 at 06:31:39Z, and the GHSA went public the same day. Roughly two days from report to patched release, with the advisory published rather than quietly folded into a changelog.
What they shipped matters more than how fast. The commit is 938 lines changed across three files, titled “Isolate Python tool execution with OS-level sandbox”, and its message states the diagnosis in the first paragraph: the runner “relied on an in-process import/AST blacklist. It could be bypassed by reaching os/subprocess through public attributes of allowed standard-library modules (e.g. glob.os.system).” The response has three parts, quoting the commit message:
- A new self-contained runner with “an import allowlist instead of a blacklist, plus attribute-name, from-import, and introspection filtering as defense in depth.”
- Execution “in a hardened, throwaway Docker container by default (no network, read-only rootfs, non-root,
--cap-drop ALL, no-new-privileges, CPU/memory/PID limits,--entrypointoverride, and crucially no Docker socket) as the real isolation boundary.” - “Fail closed: in auto/docker mode, if Docker is unavailable the call raises instead of silently running untrusted code in-process.” The subprocess path survives only as “an explicit, insecure opt-in for trusted/dev use only.”
The new python_tool_runner.py is worth reading for its comments alone, because they are an unusually honest piece of threat modelling committed to a repository. Its module docstring: “The in-process restrictions below are DEFENSE IN DEPTH only. In-process Python sandboxing is not a hard security boundary (allowed modules can re-export dangerous ones, and new escapes are found over time). The real boundary for multi-user / production deployments is OS-level isolation.” The 38-module ALLOWLIST_MODULES carries its own reasoning: enum is excluded “because it re-exports builtins under an alias that attribute-name filtering cannot catch”, and operator because “operator.attrgetter is a dynamic getattr that bypasses the static attribute checks.” A new FORBIDDEN_ATTRIBUTE_NAMES set blocks os, sys, glob, shutil, pathlib, asyncio and others as attribute names even without a leading underscore, documented as closing “the ‘import an allowed module, then reach os/sys through it’ escape” in a “version independent” way — and the comment names uuid.os, datetime.sys and typing.sys as live examples inside the allowlist itself.
That last detail is the point. The maintainers added the attribute-name filter and said in the same file that it is not the boundary. The boundary is the container.
Read it against yesterday’s briefing
We spent yesterday on five Roo Code approval bypasses whose common root was a denylist assembled from attacks its author had heard of, sitting downstream of a parser whose failures its author had not enumerated — and on Mistral Vibe, which used a real grammar and lost anyway. Heym is the same genus: an enumerate-the-bad defence applied to a substrate (the Python module graph) whose reachable surface nobody can enumerate. The difference is entirely in what happened next. Roo Code’s five bypasses are permanently unfixed in an archived repository. Heym’s maintainers treated the bypass as evidence that the mechanism class was wrong, inverted the list, and then declined to trust the inverted list either.
If you operate an agent platform that executes user-authored code — and increasingly that is the whole product category — the transferable rule is the one Heym wrote into its own source: an in-process language-level sandbox is a usability filter that produces nice error messages, not a security boundary. Put the OS between the tool and the host, and make the degraded path fail closed, because the interesting failure is not the clever escape, it is the day Docker was unavailable and the code ran in-process anyway.
What to do
Upgrade past 0.0.40. If you are running a Heym instance at all, you are almost certainly far past it — the project ships near-daily, and the current release is v0.0.129 (12 October 2026), 89 tags after the fix. The practical risk is a pinned or forgotten deployment: the affected range is “≤ commit ddc8941 (≤ 0.0.39)”, so check the running commit, not the folder name. Two configuration checks are worth doing regardless of version: confirm HEYM_PYTHON_TOOL_SANDBOX is auto or docker and that nobody set it to subprocess to get past a Docker problem in staging, and confirm ALLOW_REGISTER is explicitly false on any instance reachable beyond your network — the application default is True and only the compose file overrides it.
What we verified, and what we did not
The CVE identifier, publication timestamp, Deferred status, CNA, CVSS 3.1 vector and score, the three CWE assignments, the description and the reference list were read first-hand from the NVD 2.0 API. GHSA-wcgw-9hfw-f6f2 — title, High severity, publisher mbakgun, publication date 12 June 2026, affected range “≤ ddc8941 (≤ 0.0.39)”, patched 0.0.40, and the full disclosure text including the root-cause explanation, the PoC, the additional vectors, the registration-default reasoning, the suggested CWE rationale and the recommended fix — was read first-hand from the repository’s advisory page; all quoted passages are verbatim from it. The vulnerable python_tool_executor.py was read in full at parent commit df392da7; the fix commit b36d795c (date, full message, 938 lines across three files) and the new python_tool_runner.py were read first-hand at that commit, as was the rewritten executor docstring at main; allow_register: bool = True was read at backend/app/config.py line 52 pre-fix. Release dates for v0.0.39, v0.0.40, v0.0.41 and v0.0.129, the tag list, star and fork counts and the last-push timestamp come from the GitHub API. The standard-library re-export behaviour was checked locally on CPython 3.14.7 by attribute inspection only.
We did not install Heym, did not create an account, did not build a workflow, did not run the proof of concept, and sent no request to any Heym deployment. The PoC and the additional vectors are the researcher’s, reported as theirs; the researcher states their own testing was “tested locally against the repository code only. No production Heym instance was accessed or tested.” We also make no claim about whether any real-world exploitation occurred — nothing in the public record speaks to that either way. The GitHub advisories API returned no global-database entry for this CVE at the time of writing, so the advisory content above comes from the repository-hosted page rather than the aggregated database.
Sources:
- NVD — CVE-2026-55376, Heym Python tool sandbox escape (8.8, CWE-693/94/78, published 13 October 2026)
- GHSA-wcgw-9hfw-f6f2 — “heym Python Tool Sandbox Escape”, published by mbakgun, 12 June 2026, with root cause, PoC and recommended fix
- heymrun/heym commit b36d795c — “Isolate Python tool execution with OS-level sandbox” (11 June 2026)
- heymrun/heym — python_tool_runner.py, the import allowlist and the “not a hard security boundary” docstring
- heymrun/heym — v0.0.40 release (12 June 2026), the first fixed version
- al-ice.ai — Roo Code’s five approval bypasses and Mistral Vibe: the denylist and the real parser both lose to the shell