1414import json
1515import os
1616import traceback
17+ from pathlib import Path
1718from code_puppy .undo_manager import UndoManager
1819import warnings
1920from typing import Annotated , Any , Dict , List , Union
4546)
4647
4748
49+ def _split_existing (path : Path ) -> tuple [Path , tuple [str , ...]]:
50+ """Deepest existing ancestor of *path*, plus the missing trailing names."""
51+ missing : list [str ] = []
52+ current = path
53+ while True :
54+ if current .exists ():
55+ return current .resolve (), tuple (reversed (missing ))
56+ parent = current .parent
57+ if parent == current :
58+ return current , tuple (reversed (missing ))
59+ missing .append (current .name )
60+ current = parent
61+
62+
63+ def _casefold_has_prefix (parts : tuple [str , ...], prefix : tuple [str , ...]) -> bool :
64+ if len (parts ) < len (prefix ):
65+ return False
66+ return all (a .casefold () == b .casefold () for a , b in zip (prefix , parts ))
67+
68+
69+ def _is_inside_user_plugin_root (target : Path , root : Path ) -> bool :
70+ """Containment that survives APFS case-folding. ``Path.resolve`` does not."""
71+ target_existing , target_rest = _split_existing (target )
72+ root_existing , root_rest = _split_existing (root )
73+
74+ if os .path .samefile (target_existing , root_existing ):
75+ return _casefold_has_prefix (target_rest , root_rest )
76+
77+ current = target_existing
78+ while True :
79+ if os .path .samefile (current , root_existing ):
80+ return not root_rest
81+ parent = current .parent
82+ if parent == current :
83+ return False
84+ current = parent
85+
86+
87+ def _is_user_plugin_tree_path (file_path : str ) -> bool :
88+ """True if *file_path* is inside ``~/.code_puppy/plugins``.
89+
90+ That tree is imported at the next process start with no trust ceremony.
91+ File tools must not plant ``register_callbacks.py`` there.
92+ Canonicalization errors fail closed (treated as inside).
93+ """
94+ from code_puppy .plugins import USER_PLUGINS_DIR
95+
96+ try :
97+ resolved = Path (resolve_path (file_path )).resolve ()
98+ root = Path (USER_PLUGINS_DIR ).expanduser ().resolve ()
99+ return _is_inside_user_plugin_root (resolved , root )
100+ except (OSError , RuntimeError , ValueError ):
101+ return True
102+
103+
104+ def _refuse_user_plugin_tree (file_path : str ) -> Dict [str , Any ] | None :
105+ if not _is_user_plugin_tree_path (file_path ):
106+ return None
107+ return {
108+ "success" : False ,
109+ "path" : file_path ,
110+ "message" : (
111+ "Refused: file tools cannot modify ~/.code_puppy/plugins. "
112+ "That directory is imported at startup."
113+ ),
114+ "changed" : False ,
115+ }
116+
117+
48118def _permission_denied (permission_results : List [Any ]) -> bool :
49119 """Return True when any permission callback explicitly denies.
50120
@@ -424,6 +494,9 @@ def _write_to_file(
424494def delete_snippet_from_file (
425495 context : RunContext , file_path : str , snippet : str , message_group : str | None = None
426496) -> Dict [str , Any ]:
497+ refused = _refuse_user_plugin_tree (file_path )
498+ if refused is not None :
499+ return refused
427500 # Use the plugin system for permission handling with operation data
428501 from code_puppy .callbacks import on_file_permission
429502
@@ -452,6 +525,9 @@ def write_to_file(
452525 overwrite : bool ,
453526 message_group : str | None = None ,
454527) -> Dict [str , Any ]:
528+ refused = _refuse_user_plugin_tree (path )
529+ if refused is not None :
530+ return refused
455531 # Use the plugin system for permission handling with operation data
456532 from code_puppy .callbacks import on_file_permission
457533
@@ -481,6 +557,9 @@ def replace_in_file(
481557 replacements : List [Dict [str , str ]],
482558 message_group : str | None = None ,
483559) -> Dict [str , Any ]:
560+ refused = _refuse_user_plugin_tree (path )
561+ if refused is not None :
562+ return refused
484563 # Use the plugin system for permission handling with operation data
485564 from code_puppy .callbacks import on_file_permission
486565
@@ -504,6 +583,9 @@ async def delete_snippet_from_file_async(
504583 context : RunContext , file_path : str , snippet : str , message_group : str | None = None
505584) -> Dict [str , Any ]:
506585 """Async permission-aware variant of ``delete_snippet_from_file``."""
586+ refused = _refuse_user_plugin_tree (file_path )
587+ if refused is not None :
588+ return refused
507589 from code_puppy .callbacks import on_file_permission_async
508590
509591 operation_data = {"snippet" : snippet }
@@ -530,6 +612,9 @@ async def write_to_file_async(
530612 message_group : str | None = None ,
531613) -> Dict [str , Any ]:
532614 """Async permission-aware variant of ``write_to_file``."""
615+ refused = _refuse_user_plugin_tree (path )
616+ if refused is not None :
617+ return refused
533618 from code_puppy .callbacks import on_file_permission_async
534619
535620 operation_data = {"content" : content , "overwrite" : overwrite }
@@ -556,6 +641,9 @@ async def replace_in_file_async(
556641 message_group : str | None = None ,
557642) -> Dict [str , Any ]:
558643 """Async permission-aware variant of ``replace_in_file``."""
644+ refused = _refuse_user_plugin_tree (path )
645+ if refused is not None :
646+ return refused
559647 from code_puppy .callbacks import on_file_permission_async
560648
561649 operation_data = {"replacements" : replacements }
@@ -726,6 +814,9 @@ async def _edit_file_async(
726814def _delete_file (
727815 context : RunContext , file_path : str , message_group : str | None = None
728816) -> Dict [str , Any ]:
817+ refused = _refuse_user_plugin_tree (file_path )
818+ if refused is not None :
819+ return refused
729820 UndoManager ().record_change (file_path , "delete_file" )
730821 file_path = resolve_path (file_path )
731822
@@ -786,6 +877,9 @@ async def _delete_file_async(
786877 context : RunContext , file_path : str , message_group : str | None = None
787878) -> Dict [str , Any ]:
788879 """Async permission-aware variant of ``_delete_file``."""
880+ refused = _refuse_user_plugin_tree (file_path )
881+ if refused is not None :
882+ return refused
789883 file_path = resolve_path (file_path )
790884
791885 from code_puppy .callbacks import on_file_permission_async
0 commit comments