Skip to content

Commit 87504e9

Browse files
committed
Merge branch 'main' into v0.15.0-release-docs
2 parents 571c7e4 + ccc4386 commit 87504e9

7 files changed

Lines changed: 237 additions & 17 deletions

File tree

Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ WORKDIR /app
2424
RUN uv sync --locked
2525

2626
# Create necessary directories and set ownership
27-
RUN mkdir -p /app/data /app/.basic-memory && \
27+
RUN mkdir -p /app/data/basic-memory /app/.basic-memory && \
2828
chown -R appuser:${GID} /app
2929

3030
# Set default data directory and add venv to PATH
31-
ENV BASIC_MEMORY_HOME=/app/data \
31+
ENV BASIC_MEMORY_HOME=/app/data/basic-memory \
32+
BASIC_MEMORY_PROJECT_ROOT=/app/data \
3233
PATH="/app/.venv/bin:$PATH"
3334

3435
# Switch to the non-root user

src/basic_memory/api/routers/project_router.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ async def add_project(
194194
Response confirming the project was added
195195
"""
196196
try: # pragma: no cover
197+
# The service layer now handles cloud mode validation and path sanitization
197198
await project_service.add_project(
198199
project_data.name, project_data.path, set_default=project_data.set_default
199200
)

src/basic_memory/config.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ class BasicMemoryConfig(BaseSettings):
103103
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
104104
)
105105

106+
# Project path constraints
107+
project_root: Optional[str] = Field(
108+
default=None,
109+
description="If set, all projects must be created underneath this directory. Paths will be sanitized and constrained to this root. If not set, projects can be created anywhere (default behavior).",
110+
)
111+
106112
# API connection configuration
107113
api_url: Optional[str] = Field(
108114
default=None,
@@ -232,6 +238,10 @@ def data_dir_path(self):
232238
return Path.home() / DATA_DIR_NAME
233239

234240

241+
# Module-level cache for configuration
242+
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
243+
244+
235245
class ConfigManager:
236246
"""Manages Basic Memory configuration."""
237247

@@ -253,12 +263,45 @@ def config(self) -> BasicMemoryConfig:
253263
return self.load_config()
254264

255265
def load_config(self) -> BasicMemoryConfig:
256-
"""Load configuration from file or create default."""
266+
"""Load configuration from file or create default.
267+
268+
Environment variables take precedence over file config values,
269+
following Pydantic Settings best practices.
270+
271+
Uses module-level cache for performance across ConfigManager instances.
272+
"""
273+
global _CONFIG_CACHE
274+
275+
# Return cached config if available
276+
if _CONFIG_CACHE is not None:
277+
return _CONFIG_CACHE
257278

258279
if self.config_file.exists():
259280
try:
260-
data = json.loads(self.config_file.read_text(encoding="utf-8"))
261-
return BasicMemoryConfig(**data)
281+
file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
282+
283+
# First, create config from environment variables (Pydantic will read them)
284+
# Then overlay with file data for fields that aren't set via env vars
285+
# This ensures env vars take precedence
286+
287+
# Get env-based config fields that are actually set
288+
env_config = BasicMemoryConfig()
289+
env_dict = env_config.model_dump()
290+
291+
# Merge: file data as base, but only use it for fields not set by env
292+
# We detect env-set fields by comparing to default values
293+
merged_data = file_data.copy()
294+
295+
# For fields that have env var overrides, use those instead of file values
296+
# The env_prefix is "BASIC_MEMORY_" so we check those
297+
for field_name in BasicMemoryConfig.model_fields.keys():
298+
env_var_name = f"BASIC_MEMORY_{field_name.upper()}"
299+
if env_var_name in os.environ:
300+
# Environment variable is set, use it
301+
merged_data[field_name] = env_dict[field_name]
302+
303+
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
304+
return _CONFIG_CACHE
262305
except Exception as e: # pragma: no cover
263306
logger.exception(f"Failed to load config: {e}")
264307
raise e
@@ -268,8 +311,11 @@ def load_config(self) -> BasicMemoryConfig:
268311
return config
269312

270313
def save_config(self, config: BasicMemoryConfig) -> None:
271-
"""Save configuration to file."""
314+
"""Save configuration to file and invalidate cache."""
315+
global _CONFIG_CACHE
272316
save_basic_memory_config(self.config_file, config)
317+
# Invalidate cache so next load_config() reads fresh data
318+
_CONFIG_CACHE = None
273319

274320
@property
275321
def projects(self) -> Dict[str, str]:

src/basic_memory/services/project_service.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,31 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
9999
Raises:
100100
ValueError: If the project already exists
101101
"""
102-
# in cloud mode, don't allow arbitrary paths.
103-
if config.cloud_mode:
104-
basic_memory_home = os.getenv("BASIC_MEMORY_HOME")
105-
assert basic_memory_home is not None
106-
base_path = Path(basic_memory_home)
107-
108-
# Resolve to absolute path
109-
resolved_path = Path(os.path.abspath(os.path.expanduser(base_path / path))).as_posix()
102+
# If project_root is set, constrain all projects to that directory
103+
project_root = self.config_manager.config.project_root
104+
if project_root:
105+
base_path = Path(project_root)
106+
107+
# Sanitize the input path
108+
# Strip leading slashes, home directory references, and parent directory references
109+
clean_path = path.lstrip("/").replace("~/", "").replace("~", "")
110+
111+
# Remove any parent directory traversal attempts
112+
path_parts = []
113+
for part in clean_path.split("/"):
114+
if part and part != "." and part != "..":
115+
path_parts.append(part)
116+
clean_path = "/".join(path_parts) if path_parts else ""
117+
118+
# Construct path relative to project_root
119+
resolved_path = (base_path / clean_path).resolve().as_posix()
120+
121+
# Verify the resolved path is actually under project_root
122+
if not resolved_path.startswith(base_path.resolve().as_posix()):
123+
raise ValueError(
124+
f"BASIC_MEMORY_PROJECT_ROOT is set to {project_root}. "
125+
f"All projects must be created under this directory. Invalid path: {path}"
126+
)
110127
else:
111128
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
112129

test-int/test_disable_permalinks_integration.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
"""Integration tests for the disable_permalinks configuration."""
22

33
import pytest
4-
from pathlib import Path
5-
from textwrap import dedent
64

75
from basic_memory.config import BasicMemoryConfig
86
from basic_memory.markdown import EntityParser, MarkdownProcessor
9-
from basic_memory.models import Project
107
from basic_memory.repository import (
118
EntityRepository,
129
ObservationRepository,

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
7878
def config_manager(
7979
app_config: BasicMemoryConfig, project_config: ProjectConfig, config_home: Path, monkeypatch
8080
) -> ConfigManager:
81+
# Invalidate config cache to ensure clean state for each test
82+
from basic_memory import config as config_module
83+
84+
config_module._CONFIG_CACHE = None
85+
8186
# Create a new ConfigManager that uses the test home directory
8287
config_manager = ConfigManager()
8388
# Update its paths to use the test directory

tests/services/test_project_service.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)