is_container() detection accuracy: false positives and misses

NousResearch/hermes-agent · cluster 44721 · agent

Data as of

Nothing open on GitHub. Fix merged in #47131. Check it covers your case.

The short answer

0open issues
0open PRs
18items grouped
Known?
Yes. 18 related items grouped here, 5 issues already closed.
Fix event
A fix was merged to main in PR #47131. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #47131, #105351. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes center on is_container() incorrectly identifying bare hosts as containers (false positives) or failing to detect Kubernetes/containerd/cgroup-v2 environments (false negatives). Both failure modes cause downstream problems such as wrong supervision behavior, gateway crashes, and broken UI controls.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #47131 fix(cli): detect containerd/CRI cgroup-v2 containers in is_container()

    merged to main

    files: hermes_constants.py, tests/test_hermes_constants.py

    Show the diff
    • hermes_constants.py
      @@ -461,11 +461,21 @@ def is_wsl() -> bool:
       
       
       def is_container() -> bool:
      -    """Return True when running inside a Docker/Podman container.
      +    """Return True when running inside a container.
       
      -    Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman),
      -    and ``/proc/1/cgroup`` for container runtime markers.  Result is
      -    cached for the process lifetime.  Import-safe — no heavy deps.
      +    Recognizes Docker (``/.dockerenv``), Podman (``/run/.containerenv``),
      +    and — via ``/proc/1/cgroup`` — the docker/podman/lxc cgroup-v1 markers.
      +
      +    cgroup v2 collapses ``/proc/1/cgroup`` to a single ``0::/`` line with no
      +    runtime marker, so containerd/CRI-O runtimes (the common case on
      +    Kubernetes/k3s) were previously missed. To cover those, also check:
      +      * ``KUBERNETES_SERVICE_HOST`` env var — set in every Kubernetes pod.
      +      * ``kubepods`` / ``containerd`` / ``crio`` markers in ``/proc/1/cgroup``.
      +      * the same markers in ``/proc/self/mountinfo`` (cgroup-v2 fallback).
      +
      +    Result is cached for the process lifetime.  Import-safe — no heavy deps.
      +
      +    See: NousResearch/hermes-agent#47111
           """
           global _container_detected
           if _container_detected is not None:
      @@ -476,10 +486,26 @@ def is_container() -> bool:
           if os.path.exists("/run/.containerenv"):
               _container_detected = True
               return True
      +    # Kubernetes always injects this into pod containers; absent on hosts.
      +    if os.environ.get("KUBERNETES_SERVICE_HOST"):
      +        _container_detected = True
      +        return True
      +    _CGROUP_MARKERS = ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio")
           try:
               with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
                   cgroup = f.read()
      -            if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
      +            if any(marker in cgroup for marker in _CGROUP_MARKERS):
      +                _container_detected = True
      +                return True
      +    except OSError:
      +        pass
      +    # cgroup v2: /proc/1/cgroup is just "0::/" with no marker. The container
      +    # runtime still shows up in the mount table (overlay rootfs, runtime mount
      +    # paths), so scan mountinfo as a last resort.
      +    try:
      +        with open("/proc/self/mountinfo", "r", encoding="utf-8") as f:
      +            mountinfo = f.read()
      +            if any(marker in mountinfo for marker in ("kubepods", "containerd", "crio")):
                       _container_detected = True
                       return True
           except OSError:
    • tests/test_hermes_constants.py
      @@ -139,13 +139,67 @@ def test_negative_case(self, monkeypatch, tmp_path):
               """Returns False on a regular Linux host."""
               import builtins
               self._reset_cache(monkeypatch)
      +        monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False)
               monkeypatch.setattr(os.path, "exists", lambda p: False)
               cgroup_file = tmp_path / "cgroup"
               cgroup_file.write_text("12:memory:/\n")
      +        mountinfo_file = tmp_path / "mountinfo"
      +        mountinfo_file.write_text("22 21 0:20 / /sys rw shared:7 - sysfs sysfs rw\n")
               _real_open = builtins.open
      -        monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw))
      +
      +        def _fake_open(p, *a, **kw):
      +            if p == "/proc/1/cgroup":
      +                return _real_open(str(cgroup_file), *a, **kw)
      +            if p == "/proc/self/mountinfo":
      +                return _real_open(str(mountinfo_file), *a, **kw)
      +            return _real_open(p, *a, **kw)
      +
      +        monkeypatch.setattr("builtins.open", _fake_open)
               assert is_container() is False
       
      +    def test_detects_kubernetes_env(self, monkeypatch):
      +        """KUBERNETES_SERVICE_HOST env var triggers detection (k8s/k3s pod)."""
      +        self._reset_cache(monkeypatch)
      +        monkeypatch.setattr(os.path, "exists", lambda p: False)
      +        monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.43.0.1")
      +        assert is_container() is True
      +
      +    def test_detects_cgroup_kubepods(self, monkeypatch, tmp_path):
      +        """/proc/1/cgroup containing 'kubepods' triggers detection."""
      +        import builtins
      +        self._reset_cache(monkeypatch)
      +        monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False)
      +        monkeypatch.setattr(os.path, "exists", lambda p: False)
      +        cgroup_file = tmp_path / "cgroup"
      +        cgroup_file.write_text("12:memory:/kubepods/besteffort/podabc\n")
      +        _real_open = builtins.open
      +        monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw))
      +        assert is_container() is True
      +
      +    def test_detects_cgroup_v2_via_mountinfo(self, monkeypatch, tmp_path):
      +        """cgroup v2 (0::/ only) falls back to containerd marker in mountinfo."""
      +        import builtins
      +        self._reset_cache(monkeypatch)
      +        monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False)
      +        monkeypatch.setattr(os.path, "exists", lambda p: False)
      +        cgroup_file = tmp_path / "cgroup"
      +        cgroup_file.write_text("0::/\n")  # cgroup v2 — no runtime marker
      +        mountinfo_file = tmp_path / "mountinfo"
      +        mountinfo_file.write_text(
      +            "1234 1233 0:42 /containerd/.../rootfs / rw - overlay overlay rw\n"
      +        )
      +        _real_open = builtins.open
      +
      +        def _fake_open(p, *a, **kw):
      +            if p == "/proc/1/cgroup":
      +                return _real_open(str(cgroup_file), *a, **kw)
      +            if p == "/proc/self/mountinfo":
      +                return _real_open(str(mountinfo_file), *a, **kw)
      +            return _real_open(p, *a, **kw)
      +
      +        monkeypatch.setattr("builtins.open", _fake_open)
      +        assert is_container() is True
      +
           def test_caches_result(self, monkeypatch):
               """Second call uses cached value without re-probing."""
               monkeypatch.setattr(hermes_constants, "_container_detected", True)
  • #105351 fix(agent): a host that merely runs containers is no longer classified as one (#58135, #48594, salvage #58141)

    merged to main

    files: hermes_constants.py, tests/test_hermes_constants.py

  • #117591 fix(constants): scratch dir honours HERMES_HOME_MODE and managed-home permissions instead of forcing 0700 (#117347, salvage #117359)

    merged to main

    files: hermes_cli/config.py, hermes_constants.py, tests/hermes_cli/test_ensure_hermes_home_uid.py, tests/test_scratch_dir.py

10 PRs closed without merging

From the close comments: done elsewhere 4 · duplicate 2 · merged via another change 1 · superseded by another PR 1 · other 2

  • #44733 fix Kubernetes container detection · done elsewhere → 891c1d7, c5a3c0f, #117591, #47131
  • #44734 fix: Kubernetes containers · duplicate → #44733, #44721
  • #44777 fix(constants): detect Kubernetes/k3s pods in is_container() (#44721) · duplicate → #44733
  • #47144 fix(constants): detect containerd + cgroup v2 in is_container() (#47111) · done elsewhere → #47111, #47131, #46290
  • #48598 fix(constants): ignore host container runtime mounts · other
Show 5 more
  • #48695 fix(constants): exclude host Docker storage from is_container (#48594) · other
  • #58141 fix(agent): scan only root mount in is_container() cgroup-v2 fallback (#58135) · merged via another change → #105351, #48594
  • #58145 fix(cli): stop is_container() false-positive on hosts running containers · done elsewhere → 7b29f19, #105351, #58141
  • #65060 fix(constants): stop is_container() false positives on hosts that run containers (#65051) · done elsewhere → 7b29f19, #105351, #58141
  • #102169 fix(docker): don't classify a Docker host as a container · superseded by another PR → #58141, #48594
Closed issues · 5
  • Closed#44721Kubernetes/k3s pods are not detected as containers, so `hermes gateway start` reports unsupported platform
  • Closed#47111is_container() misses containerd + cgroup v2 (k8s/k3s) → s6 gateway supervision silently disabled → gateway double-start crash loop
  • Closed#48594Dashboard update controls hidden on systemd host with Docker/containerd running
  • Closed#58135[Bug]: `is_container()` false-positives on hosts running Docker containers (containerd markers in mountinfo), making `home_mode: auto` subprocess HOME non-deterministic — breaks browser auto-launch with "Chrome not found"
  • Closed#65051terminal.backend (docker AND ssh) silently forced to local - false "running inside a container" self-detection on bare VM hosts
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.12: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#47131, #105351, #117591
Closed issues
5
Attempts
10 PRs closed without merging
Fix link checked
20 Sep 2026

For your agent

The same facts, typed.