The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.
Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.
Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
skip-on-Windows moves onto the 11 methods that had no host mark; the
11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
platforms("linux") in favour of the module's "posix" (mkfifo exists on
macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
or wider marks removed.
151 lines
5.7 KiB
Python
151 lines
5.7 KiB
Python
"""Regression tests for TOCTOU-safe credential file writers in ``hermes_cli.auth``.
|
|
|
|
Background
|
|
==========
|
|
The three writers below used to create a temp file via ``Path.write_text`` /
|
|
``Path.open('w')`` and only ``chmod``'d it to ``0o600`` afterward. Between
|
|
create and chmod the file existed at the process umask (typically ``0o644``),
|
|
briefly exposing OAuth tokens to other local users on multi-user hosts. The
|
|
writers now go through ``utils.atomic_json_write(mode=0o600)`` whose mkstemp temp
|
|
file is ``O_EXCL`` at 0600 on creation (the cross-writer invariant lives in
|
|
``tests/agent/test_private_credential_writers.py``).
|
|
|
|
These tests stay green only while the token file and its parent directory
|
|
end up at ``0o600`` / ``0o700`` after every write. POSIX-only — the mode-bit
|
|
enforcement does not exist on Windows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
|
|
pytestmark = pytest.mark.platforms("posix") # POSIX mode bits not enforced on Windows
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _save_auth_store (~/.hermes/auth.json — every native OAuth provider)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_save_auth_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
|
"""``_save_auth_store`` must land ``auth.json`` at 0o600 and parent at 0o700."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
old_umask = os.umask(0o022) # make the race observable if it regresses
|
|
try:
|
|
from hermes_cli import auth as auth_mod
|
|
|
|
auth_store = {
|
|
"version": auth_mod.AUTH_STORE_VERSION,
|
|
"providers": {"openai-codex": {"tokens": {"access_token": "secret-x"}}},
|
|
"active_provider": "openai-codex",
|
|
}
|
|
auth_path = auth_mod._save_auth_store(auth_store)
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
mode = stat.S_IMODE(auth_path.stat().st_mode)
|
|
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
|
|
|
|
assert mode == 0o600, (
|
|
f"auth.json mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
|
)
|
|
assert parent_mode == 0o700, (
|
|
f"auth.json parent dir mode 0o{parent_mode:o} != 0o700 — siblings can traverse"
|
|
)
|
|
|
|
# Content survived the rewrite
|
|
data = json.loads(auth_path.read_text())
|
|
assert data["providers"]["openai-codex"]["tokens"]["access_token"] == "secret-x"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _save_qwen_cli_tokens (Qwen CLI OAuth tokens)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_save_qwen_cli_tokens_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
|
"""``_save_qwen_cli_tokens`` must land the token file at 0o600 and parent at 0o700."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
# The Qwen CLI auth path lives under $HOME/.qwen by default — isolate it.
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
old_umask = os.umask(0o022)
|
|
try:
|
|
from hermes_cli import auth as auth_mod
|
|
|
|
tokens = {
|
|
"access_token": "qwen-secret",
|
|
"refresh_token": "qwen-refresh",
|
|
"token_type": "Bearer",
|
|
"expiry_date": 123,
|
|
}
|
|
auth_path = auth_mod._save_qwen_cli_tokens(tokens)
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
mode = stat.S_IMODE(auth_path.stat().st_mode)
|
|
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
|
|
|
|
assert mode == 0o600, (
|
|
f"Qwen token file mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
|
)
|
|
assert parent_mode == 0o700, (
|
|
f"Qwen token parent dir mode 0o{parent_mode:o} != 0o700"
|
|
)
|
|
|
|
data = json.loads(auth_path.read_text())
|
|
assert data["access_token"] == "qwen-secret"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nous shared-credential store write (inside _write_shared_nous_state)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_shared_nous_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
|
"""The Nous shared-credential store must land at 0o600 / parent 0o700."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
# _nous_shared_store_path() refuses to touch the real shared store during
|
|
# pytest runs; redirect it into tmp_path explicitly. Use a distinct
|
|
# subdirectory name (``shared_override``) so the guard's "real user
|
|
# home" reference — which currently tracks HERMES_HOME via
|
|
# get_default_hermes_root() — can't collide with our override and
|
|
# falsely claim we're writing to the real user's shared store.
|
|
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared_override"))
|
|
old_umask = os.umask(0o022)
|
|
try:
|
|
from hermes_cli import auth as auth_mod
|
|
|
|
state = {
|
|
"access_token": "nous-access-xxx",
|
|
"refresh_token": "nous-refresh-xxx",
|
|
"token_type": "Bearer",
|
|
"scope": "openid profile",
|
|
"client_id": "test-client",
|
|
"obtained_at": "2026-01-01T00:00:00Z",
|
|
"expires_at": "2026-01-01T01:00:00Z",
|
|
}
|
|
auth_mod._write_shared_nous_state(state)
|
|
path = auth_mod._nous_shared_store_path()
|
|
finally:
|
|
os.umask(old_umask)
|
|
|
|
assert path.exists(), "shared Nous store was not written"
|
|
mode = stat.S_IMODE(path.stat().st_mode)
|
|
parent_mode = stat.S_IMODE(path.parent.stat().st_mode)
|
|
|
|
assert mode == 0o600, (
|
|
f"Nous shared store mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
|
)
|
|
assert parent_mode == 0o700, (
|
|
f"Nous shared store parent dir mode 0o{parent_mode:o} != 0o700"
|
|
)
|
|
|
|
data = json.loads(path.read_text())
|
|
assert data["refresh_token"] == "nous-refresh-xxx"
|