diff --git a/plugins/security-guidance/hooks/_base.py b/plugins/security-guidance/hooks/_base.py index 7e8eeed5..9adbcff4 100644 --- a/plugins/security-guidance/hooks/_base.py +++ b/plugins/security-guidance/hooks/_base.py @@ -10,15 +10,42 @@ import os import threading from datetime import datetime +def state_dir(): + """Return the absolute path of the plugin's state directory. + + Resolution precedence (highest first): + 1. SECURITY_WARNINGS_STATE_DIR — plugin-specific override (existing) + 2. CLAUDE_CONFIG_DIR/security — CC's config-dir env var (#1868) + 3. ~/.claude/security — default fallback + + Empty-string env vars are treated as not-set so a misconfigured shell + (`CLAUDE_CONFIG_DIR=` with no value) doesn't silently write to + /security at the filesystem root. + + Returns a fully-expanded absolute path (no literal `~`) so subprocess + callers can pass it through to code that doesn't re-expand tildes. + + Called per-invocation rather than cached at import time so test + monkeypatches of the env vars take effect — the plugin's hooks each + run as fresh subprocesses in production, so the per-call cost is + negligible compared to subprocess spawn. + """ + explicit = os.environ.get("SECURITY_WARNINGS_STATE_DIR") + if explicit: + return os.path.expanduser(explicit) + cc_config = os.environ.get("CLAUDE_CONFIG_DIR") + if cc_config: + return os.path.expanduser(os.path.join(cc_config, "security")) + return os.path.expanduser("~/.claude/security") + + # Debug log file. Lives under the plugin state dir (default ~/.claude/security/) # rather than /tmp because /tmp is world-writable on multi-user hosts (TOCTOU / # symlink-attack surface, cross-user log leakage). Overridable per-process via -# SECURITY_GUIDANCE_DEBUG_LOG, or per-state-dir via SECURITY_WARNINGS_STATE_DIR. -_DEFAULT_STATE_DIR = os.path.expanduser( - os.environ.get("SECURITY_WARNINGS_STATE_DIR") or "~/.claude/security" -) +# SECURITY_GUIDANCE_DEBUG_LOG, or per-state-dir via SECURITY_WARNINGS_STATE_DIR +# (plugin-specific override) or CLAUDE_CONFIG_DIR (CC-wide config dir, #1868). DEBUG_LOG_FILE = os.environ.get("SECURITY_GUIDANCE_DEBUG_LOG") or os.path.join( - _DEFAULT_STATE_DIR, "log.txt" + state_dir(), "log.txt" ) # Cap the debug log so parallel-worker fleets don't fill disk. When the active # file exceeds this it's atomically rotated to .1 (overwriting any prior diff --git a/plugins/security-guidance/hooks/ensure_agent_sdk.py b/plugins/security-guidance/hooks/ensure_agent_sdk.py index 4f7d30b9..262a53a9 100644 --- a/plugins/security-guidance/hooks/ensure_agent_sdk.py +++ b/plugins/security-guidance/hooks/ensure_agent_sdk.py @@ -23,6 +23,12 @@ import sys import time from pathlib import Path +# Shared state-dir resolver: SECURITY_WARNINGS_STATE_DIR → CLAUDE_CONFIG_DIR/security +# → ~/.claude/security. See _base.state_dir for resolution precedence. Re-aliased +# here to match the existing local name (state_dir was already a local var in +# main() and _maybe_emit_user_notice). +from _base import state_dir as _resolve_state_dir + # Outcome codes for the sdk_bootstrap metric. Values are stable for telemetry. NOOP_SYSTEM = 0 # claude_agent_sdk already importable in system python NOOP_VENV = 1 # venv already built and SDK imports from it @@ -90,10 +96,7 @@ def main() -> tuple[int, str, str]: if _sdk_on_syspath(): return NOOP_SYSTEM, "", "" - state_dir = Path( - os.environ.get("SECURITY_WARNINGS_STATE_DIR") - or os.path.expanduser("~/.claude/security") - ) + state_dir = Path(_resolve_state_dir()) venv = state_dir / "agent-sdk-venv" # Windows venvs put the interpreter at Scripts\python.exe; POSIX uses bin/python. if sys.platform == "win32": @@ -239,10 +242,7 @@ def _maybe_emit_user_notice(outcome: int, pv: int) -> str | None: if outcome != HOOK_PY_INCOMPATIBLE: return None try: - state_dir = Path( - os.environ.get("SECURITY_WARNINGS_STATE_DIR") - or os.path.expanduser("~/.claude/security") - ) + state_dir = Path(_resolve_state_dir()) marker = state_dir / f".agentic_unavailable_notice_v{pv or 0}" if marker.exists(): return None diff --git a/plugins/security-guidance/hooks/llm.py b/plugins/security-guidance/hooks/llm.py index bd47aa43..5cb55f54 100644 --- a/plugins/security-guidance/hooks/llm.py +++ b/plugins/security-guidance/hooks/llm.py @@ -27,7 +27,7 @@ from typing import Optional, Tuple, Dict, Any, List import extensibility import review_api -from _base import debug_log, _record_usage, _PV, PROVENANCE_TAG # noqa: F401 +from _base import debug_log, _record_usage, _PV, PROVENANCE_TAG, state_dir as _resolve_state_dir # noqa: F401 from session_state import with_locked_state @@ -355,10 +355,7 @@ def _call_claude_via_sdk(prompt, output_schema, *, max_tokens=16000, model=None) # Try the venv ensure_agent_sdk.py builds. Same fallback logic as # agentic_review() — duplicated here so the 3P path doesn't require # the agentic path to have run first. - _state_dir = os.environ.get( - "SECURITY_WARNINGS_STATE_DIR", - os.path.expanduser("~/.claude/security"), - ) + _state_dir = _resolve_state_dir() _inject_agent_sdk_venv_into_syspath(_state_dir) try: import asyncio as _asyncio # noqa: F811 @@ -1145,10 +1142,7 @@ def agentic_review( # ~/.claude/security/ with the SDK installed; try that as a fallback # before giving up. The system import is attempted first so users # who DO have it never touch the venv. - _state_dir = os.environ.get( - "SECURITY_WARNINGS_STATE_DIR", - os.path.expanduser("~/.claude/security"), - ) + _state_dir = _resolve_state_dir() _venv_tried = _inject_agent_sdk_venv_into_syspath(_state_dir) try: import asyncio as _asyncio # noqa: F811 diff --git a/plugins/security-guidance/hooks/security_reminder_hook.py b/plugins/security-guidance/hooks/security_reminder_hook.py index ffc9ba3c..73a36f95 100755 --- a/plugins/security-guidance/hooks/security_reminder_hook.py +++ b/plugins/security-guidance/hooks/security_reminder_hook.py @@ -82,6 +82,7 @@ from _base import ( # noqa: E402,F401 PROVENANCE_TAG, PROVENANCE_BANNER, _read_plugin_version_int, _PV, _USAGE, _USAGE_LOCK, _PRICE_PER_MTOK, _PRICE_DEFAULT, _record_usage, _usage_metrics, + state_dir as _resolve_state_dir, ) import extensibility # noqa: E402 from patterns import ( # noqa: E402,F401 @@ -1971,10 +1972,7 @@ def handle_stop_hook(input_data): }) sys.exit(0) -_SDK_BOOTSTRAP_THROTTLE = os.path.join( - os.environ.get("SECURITY_WARNINGS_STATE_DIR") - or os.path.expanduser("~/.claude/security"), - ".sdk_bootstrap_spawned") +_SDK_BOOTSTRAP_THROTTLE = os.path.join(_resolve_state_dir(), ".sdk_bootstrap_spawned") def _maybe_bootstrap_agent_sdk_async(): """Fire-and-forget SDK bootstrap, for remote-pod environments. diff --git a/plugins/security-guidance/hooks/session_state.py b/plugins/security-guidance/hooks/session_state.py index 4ccd15e8..8dbadfd2 100644 --- a/plugins/security-guidance/hooks/session_state.py +++ b/plugins/security-guidance/hooks/session_state.py @@ -19,7 +19,7 @@ import os import re from datetime import datetime -from _base import debug_log +from _base import debug_log, state_dir as _state_dir def _state_key(session_id): @@ -36,20 +36,20 @@ def _state_key(session_id): def get_state_file(session_id): """Get session-specific state file path.""" - state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security")) + state_dir = _state_dir() return os.path.join(state_dir, f"security_warnings_state_{_state_key(session_id)}.json") def get_lock_file(session_id): """Get session-specific lock file path.""" - state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security")) + state_dir = _state_dir() return os.path.join(state_dir, f"security_warnings_state_{_state_key(session_id)}.lock") def cleanup_old_state_files(): """Remove state files and lock files older than 30 days.""" try: - state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security")) + state_dir = _state_dir() if not os.path.exists(state_dir): return