fix: preserve symlinks during atomic file writes (#16743)

os.replace(tmp, path) replaces the symlink itself with a regular file,
breaking users who symlink config.yaml, SOUL.md, or .env from ~/.hermes/
to a dotfiles repo or managed profile package.

Fix: resolve symlinks via os.path.realpath() before os.replace(), so the
real file is overwritten in-place while the symlink survives.

Fixed in 7 files covering all os.replace call sites:
- utils.py (atomic_json_write, atomic_yaml_write — fixes save_config)
- hermes_cli/config.py (env sanitizer, save_env_value, remove_env_value)
- tools/skill_manager_tool.py (_atomic_write_text — SOUL.md writes)
- tools/memory_tool.py (memory file writes)
- tools/skills_sync.py (manifest writes)
- cron/jobs.py (job state + output file writes)
- agent/shell_hooks.py (hook file writes)

Fixes NousResearch/hermes-agent#16743
This commit is contained in:
vominh1919 2026-04-28 09:34:55 +07:00 committed by Teknium
parent 1369dae226
commit 3ab97a32d1
7 changed files with 38 additions and 13 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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)

View File

@ -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.