diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index d0645cb3a8..13ebab4eb7 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -568,7 +568,9 @@ def save_allowlist(data: Dict[str, Any]) -> None: try: with os.fdopen(fd, "w") as fh: fh.write(json.dumps(data, indent=2, sort_keys=True)) - os.replace(tmp_path, p) + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + real_path = os.path.realpath(p) if os.path.islink(p) else p + os.replace(tmp_path, real_path) except Exception: try: os.unlink(tmp_path) diff --git a/cron/jobs.py b/cron/jobs.py index 6c0a2405b2..11a7411801 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -367,7 +367,9 @@ def save_jobs(jobs: List[Dict[str, Any]]): json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, JOBS_FILE) + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + real_path = os.path.realpath(JOBS_FILE) if os.path.islink(JOBS_FILE) else JOBS_FILE + os.replace(tmp_path, real_path) _secure_file(JOBS_FILE) except BaseException: try: @@ -863,7 +865,9 @@ def save_job_output(job_id: str, output: str): f.write(output) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, output_file) + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + real_path = os.path.realpath(output_file) if os.path.islink(output_file) else output_file + os.replace(tmp_path, real_path) _secure_file(output_file) except BaseException: try: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5d9c789283..88746f2801 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3666,7 +3666,9 @@ def sanitize_env_file() -> int: f.writelines(sanitized) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, env_path) + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + real_path = os.path.realpath(env_path) if os.path.islink(env_path) else env_path + os.replace(tmp_path, real_path) except BaseException: try: os.unlink(tmp_path) @@ -3769,7 +3771,9 @@ def save_env_value(key: str, value: str): f.writelines(lines) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, env_path) + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + real_path = os.path.realpath(env_path) if os.path.islink(env_path) else env_path + os.replace(tmp_path, real_path) # Restore original permissions before _secure_file may tighten them. if original_mode is not None: try: @@ -3825,7 +3829,9 @@ def remove_env_value(key: str) -> bool: f.writelines(new_lines) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, env_path) + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + real_path = os.path.realpath(env_path) if os.path.islink(env_path) else env_path + os.replace(tmp_path, real_path) if original_mode is not None: try: os.chmod(env_path, original_mode) diff --git a/tools/memory_tool.py b/tools/memory_tool.py index eef64e7096..6c0527ab42 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -448,7 +448,10 @@ class MemoryStore: f.write(content) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, str(path)) # Atomic on same filesystem + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + path_str = str(path) + real_path = os.path.realpath(path_str) if os.path.islink(path_str) else path_str + os.replace(tmp_path, real_path) except BaseException: # Clean up temp file on any failure try: diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index c28f421a7f..734e3fa727 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -309,7 +309,9 @@ def _atomic_write_text(file_path: Path, content: str, encoding: str = "utf-8") - try: with os.fdopen(fd, "w", encoding=encoding) as f: f.write(content) - os.replace(temp_path, file_path) + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + real_path = os.path.realpath(file_path) if os.path.islink(file_path) else file_path + os.replace(temp_path, real_path) except Exception: # Clean up temp file on error try: diff --git a/tools/skills_sync.py b/tools/skills_sync.py index cb7955c019..b82b1219fa 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -98,7 +98,9 @@ def _write_manifest(entries: Dict[str, str]): f.write(data) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, MANIFEST_FILE) + # Resolve symlinks so os.replace writes to the real file (GitHub #16743). + real_path = os.path.realpath(MANIFEST_FILE) if os.path.islink(MANIFEST_FILE) else MANIFEST_FILE + os.replace(tmp_path, real_path) except BaseException: try: os.unlink(tmp_path) diff --git a/utils.py b/utils.py index f3d38006d1..dc2a3e099e 100644 --- a/utils.py +++ b/utils.py @@ -99,8 +99,11 @@ def atomic_json_write( ) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, path) - _restore_file_mode(path, original_mode) + # Resolve symlinks so os.replace writes to the real file instead of + # replacing the symlink with a regular file (GitHub #16743). + real_path = os.path.realpath(path) if os.path.islink(path) else path + os.replace(tmp_path, real_path) + _restore_file_mode(real_path, original_mode) except BaseException: # Intentionally catch BaseException so temp-file cleanup still runs for # KeyboardInterrupt/SystemExit before re-raising the original signal. @@ -150,8 +153,11 @@ def atomic_yaml_write( f.write(extra_content) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, path) - _restore_file_mode(path, original_mode) + # Resolve symlinks so os.replace writes to the real file instead of + # replacing the symlink with a regular file (GitHub #16743). + real_path = os.path.realpath(path) if os.path.islink(path) else path + os.replace(tmp_path, real_path) + _restore_file_mode(real_path, original_mode) except BaseException: # Match atomic_json_write: cleanup must also happen for process-level # interruptions before we re-raise them.