fix(file): reject symlinked entries in /api/file/save (#4234) (#4244)

* fix(file): reject symlink saves

* chore(release): stamp v0.51.433 (Release OT) for #4234 file-save symlink guard

---------

Co-authored-by: 峯岸 亮 <1920071390@campus.ouj.ac.jp>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
authored and GitHub committed 2026-06-15 04:05:29 -07:00
1 parent f927fc3a42
commit 60ad36dab7
3 files changed
+72

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.433] — 2026-06-15 — Release OT (reject symlinked entries in /api/file/save, #4234)
### Fixed
- **Saving to a workspace symlink no longer writes through to the symlink's target.** `/api/file/save` resolved the final symlink before writing, so saving to a workspace symlink `link.txt → real.txt` overwrote `real.txt` instead of the link. It now rejects a symlinked path with a 400 (checked before the existence probe, so dangling symlinks are caught too) — completing the symlink hardening from #4217 (delete/rename/move) for the write path. `safe_resolve` already blocked out-of-workspace targets, so this closes the in-workspace overwrite-through-symlink case. (#4234)
## [v0.51.432] — 2026-06-15 — Release OS (TUI sessions discoverable in the sidebar)
### Fixed
+2
View File
@@ -14824,6 +14824,8 @@ def _handle_file_save(handler, body):
try:
ws_root = Path(s.workspace)
target = safe_resolve(ws_root, body["path"])
if (ws_root / body["path"]).is_symlink():
return bad(handler, "Cannot save to a symlinked entry")
if not target.exists():
return bad(handler, "File not found", 404)
if target.is_dir():
@@ -138,6 +138,51 @@ def test_rename_workspace_symlink_to_file_rejected(workspace):
# ── Non-symlink operations still work ───────────────────────────────────
def test_save_workspace_symlink_to_file_rejected(workspace):
real_file = workspace / "real_file.txt"
real_file.write_text("important")
link = workspace / "link_to_file.txt"
try:
os.symlink(str(real_file), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_save(
_FakeHandler(),
{"session_id": "x", "path": "link_to_file.txt", "content": "changed"},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot save to a symlinked entry" in cap["bad"][0]
assert real_file.read_text() == "important"
def test_save_dangling_symlink_rejected_not_404(workspace):
link = workspace / "dangling_save"
try:
os.symlink(str(workspace / "gone_target"), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_save(
_FakeHandler(),
{"session_id": "x", "path": "dangling_save", "content": "changed"},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot save to a symlinked entry" in cap["bad"][0]
def test_delete_real_dir_still_works(workspace):
real_dir = workspace / "deleteme"
real_dir.mkdir()
@@ -178,6 +223,25 @@ def test_rename_real_file_still_works(workspace):
# ── Existing move guard pinned ──────────────────────────────────────────
def test_save_real_file_still_works(workspace):
real_file = workspace / "save_me.txt"
real_file.write_text("old")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_save(
_FakeHandler(),
{"session_id": "x", "path": "save_me.txt", "content": "new"},
)
finally:
_restore_routes(orig)
assert "ok" in cap, f"expected success, got {cap}"
assert cap["ok"]["ok"] is True
assert cap["ok"]["size"] == len("new")
assert real_file.read_text() == "new"
def test_move_workspace_symlink_still_rejected(workspace):
real_file = workspace / "real.txt"
real_file.write_text("data")