@@ -714,3 +714,156 @@ async def test_synchronize_projects_handles_case_sensitivity_bug(
714714 db_project = await project_service .repository .get_by_name (name )
715715 if db_project :
716716 await project_service .repository .delete (db_project .id )
717+
718+
719+ @pytest .mark .skipif (os .name == "nt" , reason = "Project root constraints only tested on POSIX systems" )
720+ @pytest .mark .asyncio
721+ async def test_add_project_with_project_root_sanitizes_paths (
722+ project_service : ProjectService , config_manager : ConfigManager , tmp_path , monkeypatch
723+ ):
724+ """Test that BASIC_MEMORY_PROJECT_ROOT sanitizes and validates project paths."""
725+ # Set up project root environment
726+ project_root_path = tmp_path / "app" / "data"
727+ project_root_path .mkdir (parents = True , exist_ok = True )
728+
729+ monkeypatch .setenv ("BASIC_MEMORY_PROJECT_ROOT" , str (project_root_path ))
730+
731+ # Invalidate config cache so it picks up the new env var
732+ from basic_memory import config as config_module
733+
734+ config_module ._CONFIG_CACHE = None
735+
736+ test_cases = [
737+ # (input_path, expected_result_path, should_succeed)
738+ ("test" , str (project_root_path / "test" ), True ), # Simple relative path
739+ ("~/Documents/test" , str (project_root_path / "Documents" / "test" ), True ), # Home directory
740+ (
741+ "/tmp/test" ,
742+ str (project_root_path / "tmp" / "test" ),
743+ True ,
744+ ), # Absolute path (sanitized to relative)
745+ (
746+ "../../../etc/passwd" ,
747+ str (project_root_path ),
748+ True ,
749+ ), # Path traversal (all ../ removed, results in project_root)
750+ ("folder/subfolder" , str (project_root_path / "folder" / "subfolder" ), True ), # Nested path
751+ (
752+ "~/folder/../test" ,
753+ str (project_root_path / "test" ),
754+ True ,
755+ ), # Mixed patterns (sanitized to just 'test')
756+ ]
757+
758+ for i , (input_path , expected_path , should_succeed ) in enumerate (test_cases ):
759+ test_project_name = f"project-root-test-{ i } "
760+
761+ try :
762+ # Add the project
763+ await project_service .add_project (test_project_name , input_path )
764+
765+ if should_succeed :
766+ # Verify the path was sanitized correctly
767+ assert test_project_name in project_service .projects
768+ actual_path = project_service .projects [test_project_name ]
769+
770+ # The path should be under project_root
771+ assert actual_path .startswith (str (project_root_path )), (
772+ f"Path { actual_path } should start with { project_root_path } for input { input_path } "
773+ )
774+
775+ # Clean up
776+ await project_service .remove_project (test_project_name )
777+ else :
778+ pytest .fail (f"Expected ValueError for input path: { input_path } " )
779+
780+ except ValueError as e :
781+ if should_succeed :
782+ pytest .fail (f"Unexpected ValueError for input path { input_path } : { e } " )
783+ # Expected failure - continue to next test case
784+
785+
786+ @pytest .mark .skipif (os .name == "nt" , reason = "Project root constraints only tested on POSIX systems" )
787+ @pytest .mark .asyncio
788+ async def test_add_project_with_project_root_rejects_escape_attempts (
789+ project_service : ProjectService , config_manager : ConfigManager , tmp_path , monkeypatch
790+ ):
791+ """Test that BASIC_MEMORY_PROJECT_ROOT rejects paths that try to escape the project root."""
792+ # Set up project root environment
793+ project_root_path = tmp_path / "app" / "data"
794+ project_root_path .mkdir (parents = True , exist_ok = True )
795+
796+ # Create a directory outside project_root to verify it's not accessible
797+ outside_dir = tmp_path / "outside"
798+ outside_dir .mkdir (parents = True , exist_ok = True )
799+
800+ monkeypatch .setenv ("BASIC_MEMORY_PROJECT_ROOT" , str (project_root_path ))
801+
802+ # Invalidate config cache so it picks up the new env var
803+ from basic_memory import config as config_module
804+
805+ config_module ._CONFIG_CACHE = None
806+
807+ # All of these should succeed by being sanitized to paths under project_root
808+ # The sanitization removes dangerous patterns, so they don't escape
809+ safe_after_sanitization = [
810+ "../../../etc/passwd" ,
811+ "../../.env" ,
812+ "../../../home/user/.ssh/id_rsa" ,
813+ ]
814+
815+ for i , attack_path in enumerate (safe_after_sanitization ):
816+ test_project_name = f"project-root-attack-test-{ i } "
817+
818+ try :
819+ # Add the project
820+ await project_service .add_project (test_project_name , attack_path )
821+
822+ # Verify it was sanitized to be under project_root
823+ actual_path = project_service .projects [test_project_name ]
824+ assert actual_path .startswith (str (project_root_path )), (
825+ f"Sanitized path { actual_path } should be under { project_root_path } "
826+ )
827+
828+ # Clean up
829+ await project_service .remove_project (test_project_name )
830+
831+ except ValueError :
832+ # If it raises ValueError, that's also acceptable for security
833+ pass
834+
835+
836+ @pytest .mark .skipif (os .name == "nt" , reason = "Project root constraints only tested on POSIX systems" )
837+ @pytest .mark .asyncio
838+ async def test_add_project_without_project_root_allows_arbitrary_paths (
839+ project_service : ProjectService , config_manager : ConfigManager , tmp_path , monkeypatch
840+ ):
841+ """Test that without BASIC_MEMORY_PROJECT_ROOT set, arbitrary paths are allowed."""
842+ # Ensure project_root is not set
843+ if "BASIC_MEMORY_PROJECT_ROOT" in os .environ :
844+ monkeypatch .delenv ("BASIC_MEMORY_PROJECT_ROOT" )
845+
846+ # Force reload config without project_root
847+ from basic_memory .services import project_service as ps_module
848+
849+ monkeypatch .setattr (ps_module , "config" , config_manager .load_config ())
850+
851+ # Create a test directory
852+ test_dir = tmp_path / "arbitrary-location"
853+ test_dir .mkdir (parents = True , exist_ok = True )
854+
855+ test_project_name = "no-project-root-test"
856+
857+ try :
858+ # Without project_root, we should be able to use arbitrary absolute paths
859+ await project_service .add_project (test_project_name , str (test_dir ))
860+
861+ # Verify the path was accepted as-is
862+ assert test_project_name in project_service .projects
863+ actual_path = project_service .projects [test_project_name ]
864+ assert actual_path == str (test_dir )
865+
866+ finally :
867+ # Clean up
868+ if test_project_name in project_service .projects :
869+ await project_service .remove_project (test_project_name )
0 commit comments