session_state.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """
  2. Per-session state-file plumbing for the security-guidance plugin.
  3. Holds the JSON state file location, fcntl-locked read-modify-write helper,
  4. and old-file GC. Side-effect-free at import time (no env-var reads beyond
  5. ``CLAUDE_CODE_REMOTE_SESSION_ID`` inside the helpers).
  6. The ``atomic_check_*`` helpers that build on ``with_locked_state`` deliberately
  7. remain in ``security_reminder_hook.py`` so that tests which monkeypatch
  8. ``hook.with_locked_state`` and then call a handler still see the patched
  9. binding via the handler → ``atomic_check_*`` → bare-name lookup chain.
  10. """
  11. try:
  12. import fcntl
  13. except ImportError:
  14. fcntl = None
  15. import json
  16. import os
  17. import re
  18. from datetime import datetime
  19. from _base import debug_log, state_dir as _state_dir
  20. def _state_key(session_id):
  21. # In CCR each user turn is a new CC process with a fresh session_id; the
  22. # remote session ID is stable across those restarts. Prefer it so the
  23. # pending-warnings sweep and any unprocessed touched_paths survive.
  24. key = os.environ.get("CLAUDE_CODE_REMOTE_SESSION_ID") or session_id
  25. # The key becomes a filename component under the state dir. CC session ids
  26. # are UUIDs (sanitization is a no-op for them), but nothing in the hook
  27. # protocol guarantees that, so strip path separators and anything else
  28. # that could escape the state dir, and bound the length.
  29. return re.sub(r"[^A-Za-z0-9._-]", "_", str(key))[:128]
  30. def get_state_file(session_id):
  31. """Get session-specific state file path."""
  32. state_dir = _state_dir()
  33. return os.path.join(state_dir, f"security_warnings_state_{_state_key(session_id)}.json")
  34. def get_lock_file(session_id):
  35. """Get session-specific lock file path."""
  36. state_dir = _state_dir()
  37. return os.path.join(state_dir, f"security_warnings_state_{_state_key(session_id)}.lock")
  38. def cleanup_old_state_files():
  39. """Remove state files and lock files older than 30 days."""
  40. try:
  41. state_dir = _state_dir()
  42. if not os.path.exists(state_dir):
  43. return
  44. current_time = datetime.now().timestamp()
  45. thirty_days_ago = current_time - (30 * 24 * 60 * 60)
  46. for filename in os.listdir(state_dir):
  47. if filename.startswith("security_warnings_state_") and (
  48. filename.endswith(".json") or filename.endswith(".lock")
  49. ):
  50. file_path = os.path.join(state_dir, filename)
  51. try:
  52. file_mtime = os.path.getmtime(file_path)
  53. if file_mtime < thirty_days_ago:
  54. os.remove(file_path)
  55. except (OSError, IOError):
  56. pass
  57. # Sweep legacy lock files left at ~/.claude/ root by versions
  58. # <1.1.66, where get_lock_file() didn't honor state_dir. Same
  59. # 30-day mtime gate as above so we don't race an older
  60. # concurrent peer that may still hold an active lock.
  61. legacy_dir = os.path.expanduser("~/.claude")
  62. for filename in os.listdir(legacy_dir):
  63. if filename.startswith("security_warnings_state_") and filename.endswith(".lock"):
  64. file_path = os.path.join(legacy_dir, filename)
  65. try:
  66. if os.path.getmtime(file_path) < thirty_days_ago:
  67. os.remove(file_path)
  68. except (OSError, IOError):
  69. pass
  70. except Exception:
  71. pass
  72. def load_state(session_id):
  73. """Load the full state dict from file."""
  74. state_file = get_state_file(session_id)
  75. try:
  76. with open(state_file, "r") as f:
  77. data = json.load(f)
  78. if isinstance(data, list):
  79. return {"shown_warnings": data}
  80. if isinstance(data, dict):
  81. data.setdefault("shown_warnings", [])
  82. return data
  83. except (json.JSONDecodeError, IOError, KeyError, TypeError):
  84. pass
  85. return {"shown_warnings": []}
  86. def save_state(session_id, state):
  87. """Save the full state dict to file."""
  88. state_file = get_state_file(session_id)
  89. try:
  90. state_dir = os.path.dirname(state_file)
  91. if state_dir:
  92. os.makedirs(state_dir, exist_ok=True)
  93. with open(state_file, "w") as f:
  94. json.dump(state, f)
  95. except (IOError, OSError) as e:
  96. debug_log(f"Failed to save state file {state_file}: {e}")
  97. def with_locked_state(session_id, callback):
  98. """
  99. Execute callback with exclusive access to the state file.
  100. The callback receives the state dict and can modify it in place.
  101. State is saved after the callback returns.
  102. Returns the callback's return value.
  103. """
  104. lock_file = get_lock_file(session_id)
  105. state_dir = os.path.dirname(lock_file)
  106. try:
  107. os.makedirs(state_dir, exist_ok=True)
  108. except OSError:
  109. pass
  110. if fcntl is None:
  111. # No file locking available (Windows) — run without locking
  112. state = load_state(session_id)
  113. result = callback(state)
  114. save_state(session_id, state)
  115. return result
  116. lock_fd = None
  117. try:
  118. lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT)
  119. fcntl.flock(lock_fd, fcntl.LOCK_EX)
  120. state = load_state(session_id)
  121. result = callback(state)
  122. save_state(session_id, state)
  123. return result
  124. except (OSError, IOError) as e:
  125. debug_log(f"Lock/state operation failed: {e}")
  126. return None
  127. finally:
  128. if lock_fd is not None:
  129. try:
  130. fcntl.flock(lock_fd, fcntl.LOCK_UN)
  131. os.close(lock_fd)
  132. except (OSError, IOError):
  133. pass