From 1e9547d77ffeb4d7511594670f7cbbd1d7c4d398 Mon Sep 17 00:00:00 2001 From: Jules YZERD Date: Mon, 15 Jun 2026 23:39:14 +0200 Subject: [PATCH] fix(security-guidance): block symlink escape in extensibility config reads Before this change, _load_guidance() and _read_config() called open() directly, which transparently follows symlinks. A malicious repository could commit .claude/claude-security-guidance.md (or security-patterns.*) as a symlink pointing to any locally readable file (e.g. ~/.ssh/id_rsa). The plugin would then read that file and send its content to the Anthropic API wrapped in tags on every hook invocation. Fix: add _resolve_safe() which resolves the path with os.path.realpath() and verifies the result stays within the file's parent directory (.claude/). If the resolved path escapes that boundary, the file is skipped with a debug_log entry and treated as absent. Normal files (no symlink) are unaffected since their realpath is the file itself within the same directory. Fixes #64582 Co-Authored-By: Claude Sonnet 4.6 --- .../security-guidance/hooks/extensibility.py | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/plugins/security-guidance/hooks/extensibility.py b/plugins/security-guidance/hooks/extensibility.py index a9c7f8fe5f..b9c47eba16 100644 --- a/plugins/security-guidance/hooks/extensibility.py +++ b/plugins/security-guidance/hooks/extensibility.py @@ -102,11 +102,35 @@ def _config_paths(cwd: Optional[str], basename: str) -> List[Tuple[str, str]]: return paths +def _resolve_safe(path: str) -> Optional[str]: + """Resolve symlinks and verify the real path stays within its parent directory. + + Prevents a malicious repo from committing a symlink that points outside + .claude/ (e.g. to ~/.ssh/id_rsa), which would cause the plugin to read and + send that file's content to the Anthropic API as project security guidance. + Returns the real path when safe, or None if the file escapes its boundary. + """ + try: + real = os.path.realpath(path) + boundary = os.path.realpath(os.path.dirname(path)) + if not (real == boundary or real.startswith(boundary + os.sep)): + debug_log( + f"extensibility: skipping {path}: symlink escapes {boundary} → {real}" + ) + return None + return real + except OSError: + return None + + def _load_guidance(cwd: Optional[str]) -> str: parts = [] for label, path in _config_paths(cwd, GUIDANCE_BASENAME): + real = _resolve_safe(path) + if real is None: + continue try: - with open(path, encoding="utf-8") as f: + with open(real, encoding="utf-8") as f: txt = f.read().strip() except OSError: continue @@ -170,8 +194,11 @@ def _load_user_patterns(cwd: Optional[str]) -> List[Dict[str, Any]]: def _read_config(path: str) -> Optional[Dict[str, Any]]: """Read a YAML or JSON config file. Returns None on missing/malformed.""" + real = _resolve_safe(path) + if real is None: + return None try: - with open(path, encoding="utf-8") as f: + with open(real, encoding="utf-8") as f: raw = f.read() except OSError: return None