@@ -70,6 +70,12 @@ class BaseApplier(ABC):
7070 # with a description of how the tool expects its memory files.
7171 MEMORY_SCHEMA : str = ""
7272
73+ # Subclasses MUST override this with the directory the LLM is allowed to
74+ # write memory files into. apply_memory_via_llm() rejects any path that
75+ # does not resolve inside this directory. Defaults to Path.home() as a
76+ # minimum guard; narrow it in each applier.
77+ MEMORY_ALLOWED_BASE : Optional [Path ] = None
78+
7379 def get_manifest (self ) -> ToolManifest :
7480 """Return (or create) the manifest for this tool."""
7581 return ToolManifest (self .TOOL_NAME )
@@ -95,7 +101,15 @@ def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManife
95101 count = 0
96102
97103 for skill in skills :
98- name = skill .get ("name" , "unnamed" )
104+ raw_name = skill .get ("name" , "unnamed" )
105+ try :
106+ from skills import sanitize_skill_name
107+
108+ name = sanitize_skill_name (raw_name )
109+ except (ValueError , ImportError ):
110+ warning (f"Skipping skill with invalid name: { raw_name !r} " )
111+ continue
112+
99113 source = source_dir / name
100114 if not source .exists ():
101115 continue
@@ -212,6 +226,10 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
212226 warning (f"Raw LLM response: { response [:500 ]} " )
213227 return 0
214228
229+ # Determine the allowed write root for this applier.
230+ # Resolving at call-time so tests can monkeypatch Path.home().
231+ allowed_base = (self .MEMORY_ALLOWED_BASE or Path .home ()).resolve ()
232+
215233 # Write files
216234 count = 0
217235 for op in file_ops :
@@ -222,11 +240,21 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
222240 if not file_path or content is None :
223241 continue
224242
225- path = Path (file_path )
226- path .parent .mkdir (parents = True , exist_ok = True )
227- path .write_text (content , encoding = "utf-8" )
243+ # Security: resolve the path (collapses `..`) and assert it lands
244+ # inside the allowed base directory. Rejects prompt-injection or
245+ # hallucinated paths like /etc/cron.d/evil.
246+ resolved = Path (file_path ).resolve ()
247+ if not str (resolved ).startswith (str (allowed_base ) + "/" ) and resolved != allowed_base :
248+ warning (
249+ f"[security] Rejecting LLM-suggested write outside allowed path: "
250+ f"{ file_path !r} (resolved: { resolved } , allowed base: { allowed_base } )"
251+ )
252+ continue
253+
254+ resolved .parent .mkdir (parents = True , exist_ok = True )
255+ resolved .write_text (content , encoding = "utf-8" )
228256 manifest .record_memory (
229- file_path = str (path ),
257+ file_path = str (resolved ),
230258 content = content ,
231259 entry_ids = [e .get ("entry_id" ) or e .get ("id" , "" ) for e in collected_memory ],
232260 )
0 commit comments