diff --git a/CHANGELOG.md b/CHANGELOG.md index db40a646..7e674f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- **Read-Only Mode**: Global `--read-only` CLI flag for safe database access without modifications + - Blocks all write operations at the Store layer + - Skips database upgrades and settings saves on open + - Excludes write tools (`add_document_*`, `delete_document`) from MCP server + - Disables file monitor with warning when `--read-only` is used with `serve --monitor` + ## [0.21.0] - 2025-12-18 ### Added diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 7f216fd0..76718817 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -35,9 +35,12 @@ logger = logging.getLogger(__name__) class HaikuRAGApp: - def __init__(self, db_path: Path, config: AppConfig = Config): + def __init__( + self, db_path: Path, config: AppConfig = Config, read_only: bool = False + ): self.db_path = db_path self.config = config + self.read_only = read_only self.console = Console() async def init(self): @@ -213,13 +216,17 @@ class HaikuRAGApp: ) async def list_documents(self, filter: str | None = None): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: documents = await self.client.list_documents(filter=filter) for doc in documents: self._rich_print_document(doc, truncate=True) async def add_document_from_text(self, text: str, metadata: dict | None = None): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: doc = await self.client.create_document(text, metadata=metadata) self._rich_print_document(doc, truncate=True) self.console.print( @@ -229,7 +236,9 @@ class HaikuRAGApp: async def add_document_from_source( self, source: str, title: str | None = None, metadata: dict | None = None ): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: result = await self.client.create_document_from_source( source, title=title, metadata=metadata ) @@ -246,7 +255,9 @@ class HaikuRAGApp: ) async def get_document(self, doc_id: str): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: doc = await self.client.get_document_by_id(doc_id) if doc is None: self.console.print(f"[red]Document with id {doc_id} not found.[/red]") @@ -254,7 +265,9 @@ class HaikuRAGApp: self._rich_print_document(doc, truncate=False) async def delete_document(self, doc_id: str): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: deleted = await self.client.delete_document(doc_id) if deleted: self.console.print( @@ -268,7 +281,9 @@ class HaikuRAGApp: async def search( self, query: str, limit: int | None = None, filter: str | None = None ): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: results = await self.client.search(query, limit=limit, filter=filter) if not results: self.console.print("[yellow]No results found.[/yellow]") @@ -280,7 +295,9 @@ class HaikuRAGApp: """Display visual grounding images for a chunk.""" from textual_image.renderable import Image as RichImage - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: chunk = await self.client.chunk_repository.get_by_id(chunk_id) if not chunk: self.console.print(f"[red]Chunk with id {chunk_id} not found.[/red]") @@ -325,7 +342,9 @@ class HaikuRAGApp: verbose: Show verbose output filter: SQL WHERE clause to filter documents """ - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: try: citations = [] if deep: @@ -394,7 +413,9 @@ class HaikuRAGApp: verbose: Show AG-UI event stream during execution filter: SQL WHERE clause to filter documents """ - async with HaikuRAG(db_path=self.db_path, config=self.config) as client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as client: try: self.console.print("[bold cyan]Starting research[/bold cyan]") self.console.print(f"[bold blue]Question:[/bold blue] {question}") @@ -485,7 +506,10 @@ class HaikuRAGApp: async def rebuild(self, mode: RebuildMode = RebuildMode.FULL): async with HaikuRAG( - db_path=self.db_path, config=self.config, skip_validation=True + db_path=self.db_path, + config=self.config, + skip_validation=True, + read_only=self.read_only, ) as client: try: documents = await client.list_documents() @@ -521,7 +545,10 @@ class HaikuRAGApp: """Run database maintenance: optimize and cleanup table history.""" try: async with HaikuRAG( - db_path=self.db_path, config=self.config, skip_validation=True + db_path=self.db_path, + config=self.config, + skip_validation=True, + read_only=self.read_only, ) as client: await client.vacuum() self.console.print( @@ -534,7 +561,10 @@ class HaikuRAGApp: """Create vector index on the chunks table.""" try: async with HaikuRAG( - db_path=self.db_path, config=self.config, skip_validation=True + db_path=self.db_path, + config=self.config, + skip_validation=True, + read_only=self.read_only, ) as client: row_count = client.store.chunks_table.count_rows() self.console.print(f"Chunks in database: {row_count}") @@ -704,18 +734,27 @@ class HaikuRAGApp: enable_agui: bool = False, ): """Start the server with selected services.""" - async with HaikuRAG(self.db_path, config=self.config) as client: + async with HaikuRAG( + self.db_path, config=self.config, read_only=self.read_only + ) as client: tasks = [] - # Start file monitor if enabled + # Start file monitor if enabled (not available in read-only mode) if enable_monitor: - monitor = FileWatcher(client=client, config=self.config) - monitor_task = asyncio.create_task(monitor.observe()) - tasks.append(monitor_task) + if self.read_only: + logger.warning( + "File monitor disabled: cannot monitor files in read-only mode" + ) + else: + monitor = FileWatcher(client=client, config=self.config) + monitor_task = asyncio.create_task(monitor.observe()) + tasks.append(monitor_task) # Start MCP server if enabled if enable_mcp: - server = create_mcp_server(self.db_path, config=self.config) + server = create_mcp_server( + self.db_path, config=self.config, read_only=self.read_only + ) async def run_mcp(): if mcp_transport == "stdio": diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index bd1af435..236899df 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -26,6 +26,9 @@ cli = typer.Typer( context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True ) +# Module-level read-only flag set by callback +_read_only: bool = False + def create_app(db: Path | None = None) -> HaikuRAGApp: """Create HaikuRAGApp with loaded config and resolved database path. @@ -38,7 +41,7 @@ def create_app(db: Path | None = None) -> HaikuRAGApp: """ config = get_config() db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb" - return HaikuRAGApp(db_path=db_path, config=config) + return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only) async def check_version(): @@ -72,8 +75,15 @@ def main( "--config", help="Path to YAML configuration file", ), + read_only: bool = typer.Option( + False, + "--read-only", + help="Open database in read-only mode", + ), ): """haiku.rag CLI - Vector database RAG system""" + global _read_only + _read_only = read_only # Load config from --config, local folder, or default directory config_path = find_config_file(cli_path=config) if config_path: @@ -353,7 +363,7 @@ def research( from haiku.rag.cli_chat import interactive_research from haiku.rag.client import HaikuRAG - client = HaikuRAG(db_path=app.db_path, config=app.config) + client = HaikuRAG(db_path=app.db_path, config=app.config, read_only=_read_only) try: interactive_research( client=client, @@ -521,7 +531,7 @@ def inspect( raise typer.Exit(1) from e db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb" - run_inspector(db_path) + run_inspector(db_path, read_only=_read_only) @cli.command( diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py index edd3e9f9..cdea33c5 100644 --- a/haiku_rag_slim/haiku/rag/client.py +++ b/haiku_rag_slim/haiku/rag/client.py @@ -60,6 +60,7 @@ class HaikuRAG: config: AppConfig = Config, skip_validation: bool = False, create: bool = False, + read_only: bool = False, ): """Initialize the RAG client with a database path. @@ -68,6 +69,7 @@ class HaikuRAG: config: Configuration to use. Defaults to global Config. skip_validation: Whether to skip configuration validation on database load. create: Whether to create the database if it doesn't exist. + read_only: Whether to open the database in read-only mode. """ self._config = config if db_path is None: @@ -77,10 +79,16 @@ class HaikuRAG: config=self._config, skip_validation=skip_validation, create=create, + read_only=read_only, ) self.document_repository = DocumentRepository(self.store) self.chunk_repository = ChunkRepository(self.store) + @property + def is_read_only(self) -> bool: + """Whether the client is in read-only mode.""" + return self.store.is_read_only + async def __aenter__(self): """Async context manager entry.""" return self diff --git a/haiku_rag_slim/haiku/rag/inspector/app.py b/haiku_rag_slim/haiku/rag/inspector/app.py index d3a9fa94..b76e6f83 100644 --- a/haiku_rag_slim/haiku/rag/inspector/app.py +++ b/haiku_rag_slim/haiku/rag/inspector/app.py @@ -74,9 +74,10 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover Binding("c", "show_context", "Context", show=True), ] - def __init__(self, db_path: Path): + def __init__(self, db_path: Path, read_only: bool = False): super().__init__() self.db_path = db_path + self.read_only = read_only self.client: HaikuRAG | None = None def compose(self) -> "ComposeResult": @@ -90,7 +91,9 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover async def on_mount(self) -> None: """Initialize the app when mounted.""" config = get_config() - self.client = HaikuRAG(db_path=self.db_path, config=config) + self.client = HaikuRAG( + db_path=self.db_path, config=config, read_only=self.read_only + ) await self.client.__aenter__() # Load initial documents @@ -229,15 +232,18 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover await self._switch_modal(ContextModal(chunk=chunk, client=self.client)) -def run_inspector(db_path: Path | None = None) -> None: # pragma: no cover +def run_inspector( + db_path: Path | None = None, read_only: bool = False +) -> None: # pragma: no cover """Run the inspector TUI. Args: db_path: Path to the LanceDB database. If None, uses default from config. + read_only: Whether to open the database in read-only mode. """ config = get_config() if db_path is None: db_path = config.storage.data_dir / "haiku.rag.lancedb" - app = InspectorApp(db_path) + app = InspectorApp(db_path, read_only=read_only) app.run() diff --git a/haiku_rag_slim/haiku/rag/mcp.py b/haiku_rag_slim/haiku/rag/mcp.py index 949d44b1..f002c384 100644 --- a/haiku_rag_slim/haiku/rag/mcp.py +++ b/haiku_rag_slim/haiku/rag/mcp.py @@ -21,70 +21,91 @@ class DocumentResult(BaseModel): updated_at: str -def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: - """Create an MCP server with the specified database path.""" +def create_mcp_server( + db_path: Path, config: AppConfig = Config, read_only: bool = False +) -> FastMCP: + """Create an MCP server with the specified database path. + + Args: + db_path: Path to the database file. + config: Configuration to use. + read_only: If True, write tools (add_document_*, delete_document) are not registered. + """ mcp = FastMCP("haiku-rag") - @mcp.tool() - async def add_document_from_file( - file_path: str, - metadata: dict[str, Any] | None = None, - title: str | None = None, - ) -> str | None: - """Add a document to the RAG system from a file path.""" - try: - async with HaikuRAG(db_path, config=config) as rag: - result = await rag.create_document_from_source( - Path(file_path), title=title, metadata=metadata or {} - ) - # Handle both single document and list of documents (directories) - if isinstance(result, list): - return result[0].id if result else None - return result.id - except Exception: - return None + # Write tools - only registered when not in read-only mode + if not read_only: - @mcp.tool() - async def add_document_from_url( - url: str, metadata: dict[str, Any] | None = None, title: str | None = None - ) -> str | None: - """Add a document to the RAG system from a URL.""" - try: - async with HaikuRAG(db_path, config=config) as rag: - result = await rag.create_document_from_source( - url, title=title, metadata=metadata or {} - ) - # Handle both single document and list of documents - if isinstance(result, list): - return result[0].id if result else None - return result.id - except Exception: - return None + @mcp.tool() + async def add_document_from_file( + file_path: str, + metadata: dict[str, Any] | None = None, + title: str | None = None, + ) -> str | None: + """Add a document to the RAG system from a file path.""" + try: + async with HaikuRAG(db_path, config=config) as rag: + result = await rag.create_document_from_source( + Path(file_path), title=title, metadata=metadata or {} + ) + # Handle both single document and list of documents (directories) + if isinstance(result, list): + return result[0].id if result else None + return result.id + except Exception: + return None - @mcp.tool() - async def add_document_from_text( - content: str, - uri: str | None = None, - metadata: dict[str, Any] | None = None, - title: str | None = None, - ) -> str | None: - """Add a document to the RAG system from text content.""" - try: - async with HaikuRAG(db_path, config=config) as rag: - document = await rag.create_document( - content, uri, title=title, metadata=metadata or {} - ) - return document.id - except Exception: - return None + @mcp.tool() + async def add_document_from_url( + url: str, metadata: dict[str, Any] | None = None, title: str | None = None + ) -> str | None: + """Add a document to the RAG system from a URL.""" + try: + async with HaikuRAG(db_path, config=config) as rag: + result = await rag.create_document_from_source( + url, title=title, metadata=metadata or {} + ) + # Handle both single document and list of documents + if isinstance(result, list): + return result[0].id if result else None + return result.id + except Exception: + return None + @mcp.tool() + async def add_document_from_text( + content: str, + uri: str | None = None, + metadata: dict[str, Any] | None = None, + title: str | None = None, + ) -> str | None: + """Add a document to the RAG system from text content.""" + try: + async with HaikuRAG(db_path, config=config) as rag: + document = await rag.create_document( + content, uri, title=title, metadata=metadata or {} + ) + return document.id + except Exception: + return None + + @mcp.tool() + async def delete_document(document_id: str) -> bool: + """Delete a document by its ID.""" + try: + async with HaikuRAG(db_path, config=config) as rag: + return await rag.delete_document(document_id) + except Exception: + return False + + # Read tools - always registered @mcp.tool() async def search_documents( query: str, limit: int | None = None ) -> list[SearchResult]: """Search the RAG system for documents using hybrid search (vector similarity + full-text search).""" try: - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: return await rag.search(query, limit=limit) except Exception: return [] @@ -93,7 +114,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: async def get_document(document_id: str) -> DocumentResult | None: """Get a document by its ID.""" try: - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: document = await rag.get_document_by_id(document_id) if document is None: @@ -128,7 +149,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: List of DocumentResult instances matching the criteria. """ try: - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: documents = await rag.list_documents(limit, offset, filter) return [ @@ -146,15 +167,6 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: except Exception: return [] - @mcp.tool() - async def delete_document(document_id: str) -> bool: - """Delete a document by its ID.""" - try: - async with HaikuRAG(db_path, config=config) as rag: - return await rag.delete_document(document_id) - except Exception: - return False - @mcp.tool() async def ask_question( question: str, @@ -172,7 +184,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: The answer as a string. """ try: - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: if deep: from haiku.rag.graph.research.dependencies import ResearchContext from haiku.rag.graph.research.graph import build_research_graph @@ -222,7 +234,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: from haiku.rag.graph.research.graph import build_research_graph from haiku.rag.graph.research.state import ResearchDeps, ResearchState - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: graph = build_research_graph(config=config) context = ResearchContext(original_question=question) state = ResearchState.from_config(context=context, config=config) diff --git a/tests/store/test_read_only.py b/tests/store/test_read_only.py index 26f3ec95..5c0e7efc 100644 --- a/tests/store/test_read_only.py +++ b/tests/store/test_read_only.py @@ -1,5 +1,6 @@ import pytest +from haiku.rag.client import HaikuRAG from haiku.rag.store import ReadOnlyError, Store from haiku.rag.store.models import Chunk, Document from haiku.rag.store.repositories.chunk import ChunkRepository @@ -227,3 +228,62 @@ class TestSettingsRepositoryReadOnly: with pytest.raises(ReadOnlyError): repo.save_current_settings() store.close() + + +class TestClientReadOnly: + def test_client_default_is_not_read_only(self, temp_db_path): + """Client defaults to not read-only.""" + client = HaikuRAG(temp_db_path, create=True) + assert client.is_read_only is False + client.close() + + def test_client_can_be_created_read_only(self, temp_db_path): + """Client can be created with read_only=True.""" + client = HaikuRAG(temp_db_path, create=True) + client.close() + + client = HaikuRAG(temp_db_path, read_only=True) + assert client.is_read_only is True + client.close() + + @pytest.mark.asyncio + async def test_client_create_document_raises_when_read_only(self, temp_db_path): + """Client.create_document() raises ReadOnlyError when read_only=True.""" + client = HaikuRAG(temp_db_path, create=True) + client.close() + + async with HaikuRAG(temp_db_path, read_only=True) as client: + with pytest.raises(ReadOnlyError): + await client.create_document("test content") + + @pytest.mark.asyncio + async def test_client_delete_document_raises_when_read_only(self, temp_db_path): + """Client.delete_document() raises ReadOnlyError when read_only=True.""" + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.create_document("test content") + assert doc.id is not None + doc_id = doc.id + + async with HaikuRAG(temp_db_path, read_only=True) as client: + with pytest.raises(ReadOnlyError): + await client.delete_document(doc_id) + + @pytest.mark.asyncio + async def test_client_search_works_when_read_only(self, temp_db_path): + """Client.search() works in read-only mode.""" + async with HaikuRAG(temp_db_path, create=True) as client: + await client.create_document("test content about cats") + + async with HaikuRAG(temp_db_path, read_only=True) as client: + results = await client.search("cats") + assert len(results) > 0 + + @pytest.mark.asyncio + async def test_client_list_documents_works_when_read_only(self, temp_db_path): + """Client.list_documents() works in read-only mode.""" + async with HaikuRAG(temp_db_path, create=True) as client: + await client.create_document("test content") + + async with HaikuRAG(temp_db_path, read_only=True) as client: + docs = await client.list_documents() + assert len(docs) == 1