diff --git a/agent/runtime_self_protection.py b/agent/runtime_self_protection.py index fb38911795..ce25b6f1e0 100644 --- a/agent/runtime_self_protection.py +++ b/agent/runtime_self_protection.py @@ -152,11 +152,15 @@ def _overlaps(a: str, b: str) -> bool: def split_entry(path: str) -> tuple[str, str]: - """``os.path.split`` for a directory entry, trailing separators dropped first: an - empty leaf (``dir/link/``) would make entry checks degenerate to the link's target. + """``os.path.split`` for a directory entry, trailing separators and ``.`` components + dropped first: an empty or ``.`` leaf (``dir/link/``, ``dir/link/.``) would make entry + checks degenerate to the link's target, while pathlib/rm still act on ``link``. A bare root (``/``, ``C:\\``) is kept as is.""" drive, tail = os.path.splitdrive(path) - return os.path.split(drive + (tail.rstrip(os.sep + (os.altsep or "")) or tail[:1])) + parent, leaf = os.path.split(drive + (tail.rstrip(os.sep + (os.altsep or "")) or tail[:1])) + if leaf == "." and parent: + return split_entry(parent) + return parent, leaf def is_protected_path(path: str, *, follow: bool = True) -> Optional[str]: diff --git a/tests/tools/test_file_operations_delete.py b/tests/tools/test_file_operations_delete.py index 81ccb3f3b0..2f62058d2d 100644 --- a/tests/tools/test_file_operations_delete.py +++ b/tests/tools/test_file_operations_delete.py @@ -43,13 +43,14 @@ def test_delete_file_refuses_directory(ops, tmp_path): @pytest.mark.platforms("posix") -@pytest.mark.parametrize("op", ["delete", "delete_trailing_slash", "move"]) +@pytest.mark.parametrize("op", ["delete", "delete_trailing_slash", "delete_dot", "move"]) @pytest.mark.parametrize("layout", ["beside_runtime_venv", "link_in_credential_dir"]) def test_delete_and_move_guard_the_entry_itself(ops, tmp_path, monkeypatch, op, layout): """Delete/Move vet the directory entry (parent resolved, leaf kept): a plain file whose directory merely CONTAINS the runtime venv (``~/notes.txt``) stays deletable/movable, while a link directly inside a credential dir is refused even though it points outside. - A trailing separator (``.ssh/link/``) must not empty the leaf and skip the entry check.""" + A trailing separator or ``.`` (``.ssh/link/``, ``.ssh/link/.``) must not empty the leaf + and skip the entry check.""" home, outside = tmp_path / "home", tmp_path / "outside.txt" outside.write_text("keep", encoding="utf-8") monkeypatch.setenv("HOME", str(home)) @@ -67,7 +68,7 @@ def test_delete_and_move_guard_the_entry_itself(ops, tmp_path, monkeypatch, op, if op == "move": result = ops.move_file(str(entry), str(moved)) else: - result = ops.delete_file(str(entry) + ("/" if op == "delete_trailing_slash" else "")) + result = ops.delete_file(str(entry) + {"delete_trailing_slash": "/", "delete_dot": "/."}.get(op, "")) if layout == "beside_runtime_venv": assert result.error is None, result.error diff --git a/tests/tools/test_file_tools_cwd_resolution.py b/tests/tools/test_file_tools_cwd_resolution.py index 92b2541d45..d1dc7199e2 100644 --- a/tests/tools/test_file_tools_cwd_resolution.py +++ b/tests/tools/test_file_tools_cwd_resolution.py @@ -305,8 +305,9 @@ def test_v4a_patch_applies_to_resolved_workspace_not_backend_cwd( @pytest.mark.platforms("posix") @pytest.mark.parametrize("header,safe_root,content,dest", [ ("*** Delete File: local.yaml", False, "shared: true\n", None), - # A trailing separator must still name the link, not fall back to its target. + # A trailing separator or "." must still name the link, not fall back to its target. ("*** Delete File: local.yaml/", False, "shared: true\n", None), + ("*** Delete File: local.yaml/.", False, "shared: true\n", None), ("*** Move File: local.yaml -> old.yaml", False, "shared: true\n", "old.yaml"), ("*** Update File: local.yaml\n@@\n-shared: true\n+shared: false\n*** Move File: local.yaml -> old.yaml", False, "shared: false\n", "old.yaml"), @@ -341,7 +342,7 @@ def test_v4a_delete_and_move_act_on_a_symlink_not_its_target( "patch", {"mode": "patch", "patch": f"*** Begin Patch\n{header}\n*** End Patch\n"}, task_id=task_id)) assert target.is_file() and not target.is_symlink() - assert target.read_text(encoding="utf-8") == content + assert target.read_text(encoding="utf-8-sig") == content if safe_root: assert not out.get("success") and "HERMES_WRITE_SAFE_ROOT" in json.dumps(out), out assert link.is_symlink()