Skip to content

fix(security): close TOCTOU windows when saving credentials in hermes_cli/auth#21154

Closed
Gutslabs wants to merge 1 commit into
NousResearch:mainfrom
Gutslabs:fix/auth-credential-toctou
Closed

fix(security): close TOCTOU windows when saving credentials in hermes_cli/auth#21154
Gutslabs wants to merge 1 commit into
NousResearch:mainfrom
Gutslabs:fix/auth-credential-toctou

Conversation

@Gutslabs

@Gutslabs Gutslabs commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Three credential-persistence helpers in hermes_cli/auth.py left a TOCTOU window where OAuth tokens were briefly readable at the process umask (commonly 0o644 = world-readable) before being chmod'd to 0o600. On a multi-user host another local user could capture tokens during that window.

  • _save_auth_store (provider auth.json): used open(\"w\") + atomic_replace + post-write chmod(0o600) on the destination. The destination briefly inherited the temp file's umask-default mode after replace.
  • _save_qwen_cli_tokens (Qwen CLI OAuth tokens): used write_text + chmod tmp + replace. The temp file briefly inherited the umask before the chmod.
  • _write_shared_nous_state (cross-profile Nous refresh-token store): same write_text + chmod tmp + replace pattern.

All three now create the temp file via os.open with O_WRONLY | O_CREAT | O_EXCL and an explicit S_IRUSR | S_IWUSR mode, so 0o600 is applied at create time rather than via a follow-up chmod. The temp filenames in _save_qwen_cli_tokens and _write_shared_nous_state also gain a per-process random suffix (matching the pattern already used by _save_auth_store) so concurrent writers and stale leftovers from a crashed prior write don't collide.

Same pattern as the merged fix for agent/google_oauth.py in #19673 and the parallel fixes for tools/mcp_oauth.py (#21148) and agent/anthropic_adapter.py (#21152).

Type of Change

  • Bug fix
  • Security fix
  • Tests

Changes Made

  • hermes_cli/auth.py:
    • _save_auth_store: Replace tmp_path.open(\"w\") + post-replace chmod with os.open(O_EXCL, mode=0o600) + os.fdopen + fsync + atomic_replace. Drop the now-redundant chmod block.
    • _save_qwen_cli_tokens: Same os.open(O_EXCL, mode=0o600) pattern. Switch from fixed .tmp suffix to a per-process random suffix.
    • _write_shared_nous_state: Same fix with the same randomized suffix.
  • tests/hermes_cli/test_auth_commands.py: Add test_save_auth_store_writes_at_0o600 that forces a permissive umask (0o022) and asserts the resulting auth.json is still 0o600 — proving the TOCTOU window is closed regardless of the caller's umask. Skipped on Windows where POSIX mode bits aren't enforced. The pre-existing test_save_qwen_cli_tokens_permissions and test_shared_store_write_and_read_roundtrip already cover the other two helpers (and continue to pass).

How to Test

  1. pytest -q tests/hermes_cli/test_auth_qwen_provider.py tests/hermes_cli/test_auth_nous_provider.py tests/hermes_cli/test_auth_commands.py tests/hermes_cli/test_auth_provider_gate.py tests/hermes_cli/test_auth_codex_provider.py
  2. Confirm the new test_save_auth_store_writes_at_0o600 passes.
  3. Confirm pre-existing permission assertions in test_save_qwen_cli_tokens_permissions and test_shared_store_write_and_read_roundtrip still pass.

Reproducing the original race directly is timing-sensitive but observable on Linux with inotifywait -m -e create -e attrib ~/.hermes/: the legacy code shows the temp file or destination appearing at the process umask before the chmod, while the new code shows it at 0o600 in a single create step.

Validation

  • pytest -q tests/hermes_cli/test_auth_qwen_provider.py tests/hermes_cli/test_auth_nous_provider.py tests/hermes_cli/test_auth_commands.py tests/hermes_cli/test_auth_provider_gate.py tests/hermes_cli/test_auth_codex_provider.py -> 139 passed
  • Tested on macOS 25.4 / Python 3.14.4. POSIX-only assertion is gated on sys.platform, so Windows/CI shouldn't see false failures.

…_cli/auth

Three credential-persistence helpers in hermes_cli/auth.py left a
TOCTOU window where OAuth tokens were briefly readable at the process
umask (commonly 0o644 = world-readable) before being chmod'd to 0o600.
On a multi-user host another local user could capture the tokens
during that window.

- _save_auth_store: open("w") + atomic_replace + post-write
  chmod(0o600) on the destination. The destination briefly inherited
  the temp file's umask-default mode after replace.
- _save_qwen_cli_tokens: write_text + chmod tmp + replace. The temp
  file briefly inherited the umask before the chmod.
- _write_shared_nous_state (the cross-profile Nous refresh-token
  store): same write_text + chmod tmp + replace pattern.

All three now create the temp file via os.open with O_WRONLY|O_CREAT
|O_EXCL and an explicit S_IRUSR|S_IWUSR mode, so 0o600 is applied at
create time rather than via a follow-up chmod. The temp filenames in
_save_qwen_cli_tokens and _write_shared_nous_state also gain a
per-process random suffix (matching the pattern already used by
_save_auth_store) so concurrent writers and stale leftovers from a
crashed prior write don't collide.

Mirrors the fix shipped for agent/google_oauth.py in NousResearch#19673,
tools/mcp_oauth.py in NousResearch#21148, and agent/anthropic_adapter.py in NousResearch#21152.

Adds a regression test in tests/hermes_cli/test_auth_commands.py
asserting _save_auth_store lands the file at 0o600 even under a
permissive umask (0o022). The pre-existing
test_save_qwen_cli_tokens_permissions and
test_shared_store_write_and_read_roundtrip already cover the other
two helpers.
@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools labels May 7, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Closing as superseded by #21194.

Triage notes (high confidence):
Merged PR #21194 (commit 042eb93 'fix(security): close TOCTOU window in hermes_cli/auth.py credential writers') already applies the O_EXCL+0o600 fix to _save_auth_store (hermes_cli/auth.py:1028), _save_qwen_cli_tokens (line 1922), and _write_shared_nous_state (line 4195) on main.

Thanks for the contribution — the underlying problem this PR addresses has been resolved by the linked PR on current main. If you believe this was closed in error, please comment and we'll reopen.

(Bulk-closed during a CLI PR triage sweep.)

@teknium1 teknium1 closed this May 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants