{"repo":"NousResearch/hermes-agent","public_id":"48441","display_label":"filtering secrets from terminal snapshot files","summary":"Multiple issues and fixes address terminal session snapshots capturing credential-bearing environment variables to disk. Proposed mitigations include filtering sensitive env vars from snapshots and restricting snapshot file permissions to owner-only.","subsystem":"environments","counts":{"members":6,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60199,"merged_at":"2026-07-07T12:22:43+00:00"},"resolution":{"state":"partially_resolved","by":"pr-60199"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T12:22:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":48441,"pr_number":60199,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-07T12:22:43+00:00"}],"members":[{"kind":"issue","number":48441,"title":"[Security] Terminal session snapshots leak .env secrets to disk in plaintext (cache/terminal/hermes-snap-*.sh)","url":null,"state":"closed"},{"kind":"issue","number":62336,"title":"[Security] Terminal environment snapshots capture credential-bearing env vars to disk","url":null,"state":"open"},{"kind":"pr","number":48443,"title":"fix(environments): filter secret env vars from terminal snapshots","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48441","number":48441},{"kind":"pr","value":"#38249","number":38249}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":56952,"title":"[codex] fix terminal snapshot secret filtering","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48441","number":48441},{"kind":"pr","value":"#41278","number":41278}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":60199,"title":"fix(security): create terminal env snapshots owner-only (0600)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_base_environment.py","tools/environments/base.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -1811,6 +1811,7 @@\n     \"[redacted:email]\": \"vanthinh6886\",  # PR #28018 salvage (yaml/flock/atomic write guards)\n     \"[redacted:email]\": \"erikengervall\",  # PR #28774 (firecrawl integration tag)\n     \"[redacted:email]\": \"egilewski\",  # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8)\n+    \"[redacted:email]\": \"andrewhomeyer\",  # PR #20056 (snapshot owner-only perms, co-author on #57386 salvage)\n     \"[redacted:email]\": \"McClean-Edison\",  # PR #29817 (register_auxiliary_task plugin API)\n     \"OYLFLMH@users.noreply.github.com\": \"OYLFLMH\",  # PR #48312 salvage (cli_refresh_interval config, #48309)\n     \"[redacted:email]\": \"SamuelZ12\",  # PR #7480 (show recap after in-session resume)"]},{"file":"tests/tools/test_base_environment.py","hunks":["@@ -174,6 +174,32 @@ def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None):\n         assert \"$BASHPID\" in boot\n         assert \".tmp.$$\" not in boot\n \n+    def test_snapshot_writes_use_private_umask_after_user_command(self):\n+        env = _TestableEnv()\n+        env._snapshot_ready = True\n+        wrapped = env._wrap_command(\"echo hi\", \"/tmp\")\n+\n+        assert \"umask 077\" in wrapped\n+        assert wrapped.index(\"eval 'echo hi'\") < wrapped.index(\"umask 077\")\n+        assert wrapped.index(\"umask 077\") < wrapped.index(\"export -p >\")\n+\n+    def test_init_session_bootstrap_uses_private_umask(self):\n+        env = _TestableEnv()\n+        captured = {}\n+\n+        def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None):\n+            captured[\"cmd\"] = cmd_string\n+            raise RuntimeError(\"stop after capture\")\n+\n+        env._run_bash = fake_run_bash  # type: ignore[assignment]\n+        try:\n+            env.init_session()\n+        except Exception:\n+            pass\n+        boot = captured.get(\"cmd\", \"\")\n+        assert \"umask 077\" in boot\n+        assert boot.index(\"umask 077\") < boot.index(\"export -p >\")\n+\n \n class TestAtomicSnapshotConcurrencyBehavioral:\n     \"\"\"Behavioral regression for #38249 — actually EXECUTES the generated","@@ -250,6 +276,57 @@ def test_failed_export_does_not_destroy_good_snapshot(self, tmp_path):\n         assert \"export GOOD=1\" in out.stdout, \"good snapshot was destroyed by a failed export\"\n \n \n+class TestSnapshotFileModes:\n+    \"\"\"Snapshot metadata files are private without changing user command umask.\"\"\"\n+\n+    def test_snapshot_and_cwd_files_are_0600(self, tmp_path):\n+        import os\n+        from pathlib import Path\n+        import shutil\n+        import stat\n+        import subprocess\n+        if not shutil.which(\"bash\"):\n+            import pytest\n+            pytest.skip(\"bash required\")\n+\n+        class ExecutableEnv(BaseEnvironment):\n+            def __init__(self, temp_dir):\n+                self._temp_dir = str(temp_dir)\n+                super().__init__(cwd=str(temp_dir), timeout=10)\n+\n+            def get_temp_dir(self):\n+                return self._temp_dir\n+\n+            def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):\n+                proc = subprocess.Popen(\n+                    [\"/bin/bash\", \"-lc\", cmd_string],\n+                    stdout=subprocess.PIPE,\n+                    stderr=subprocess.STDOUT,\n+                    stdin=subprocess.DEVNULL,\n+                    text=True,\n+                    cwd=self.cwd,\n+                )\n+                proc.communicate(timeout=timeout)\n+                return proc\n+\n+            def cleanup(self):\n+                pass\n+\n+        old_umask = os.umask(0o022)\n+        try:\n+            env = ExecutableEnv(tmp_path)\n+            env.init_session()\n+\n+            user_file = tmp_path / \"user-created.txt\"\n+            env.execute(f\"touch {user_file}\")\n+\n+            assert stat.S_IMODE(user_file.stat().st_mode) == 0o644\n+            assert stat.S_IMODE(Path(env._snapshot_path).stat().st_mode) == 0o600\n+            assert stat.S_IMODE(Path(env._cwd_file).stat().st_mode) == 0o600\n+        finally:\n+            os.umask(old_umask)\n+\n+\n class TestExtractCwdFromOutput:\n     def test_happy_path(self):\n         env = _TestableEnv()"]},{"file":"tools/environments/base.py","hunks":["@@ -395,6 +395,7 @@ def init_session(self):\n         # with ``$BASHPID`` left outside the quotes so it still expands.\n         _snap_tmp = shlex.quote(self._snapshot_path + \".tmp.\") + \"$BASHPID\"\n         bootstrap = (\n+            f\"umask 077\\n\"\n             f\"export -p > {_snap_tmp}\\n\"\n             # Dump function definitions, filtering out private (``_``-prefixed)\n             # helpers — mainly bash-completion internals (``_git``, ``_make``…)","@@ -502,6 +503,9 @@ def _wrap_command(self, command: str, cwd: str) -> str:\n         # Run the actual command\n         parts.append(f\"eval '{escaped}'\")\n         parts.append(\"__hermes_ec=$?\")\n+        # Restrict Hermes metadata files without changing the user's command\n+        # umask. Snapshot files may contain env-carried secrets.\n+        parts.append(\"umask 077\")\n \n         # Re-dump env vars to snapshot (atomic replacement to avoid races).\n         # Chain mv on the export succeeding so a failed/partial dump never\n"]}]},{"kind":"pr","number":62346,"title":"fix(tools): filter credential-bearing env vars from terminal snapshots","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a1e6ea7d7","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}