diff --git a/CHANGELOG.md b/CHANGELOG.md index 8faa88d95..32c67efc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Changing a knowledge document's category keeps it inside `knowledge/`.** + `PATCH /knowledge/documents/{doc_id}` with `category_id` set to `..` moved + the document's directory up into the project directory, where the markdown + scan no longer finds it. The category now goes through the same rule as + document creation, so `.` and `..` fall back to `Others`, and the move is + refused if its target would leave `knowledge/`. A document an earlier move + left outside is moved back on its next category change. Reported by + White0xdi3. + ## [1.4.1] - 2026-09-24 **A fresh install works again.** The `openai` SDK released its 3.x line on diff --git a/SECURITY.md b/SECURITY.md index e2be93fd0..79f72036a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,8 +7,8 @@ not receive backports. | Version | Supported | |---------|-----------| -| `1.2.x` (current) | ✅ | -| `1.1.x` and older | ❌ — upgrade to the current line | +| `1.4.x` (current) | ✅ | +| `1.3.x` and older | ❌ — upgrade to the current line | ## Reporting a Vulnerability @@ -58,3 +58,10 @@ following in mind: the providers you configure. - Memory content is stored as plaintext `.md` files; apply OS-level file permissions or disk encryption if your data is sensitive. +- **What counts as a vulnerability.** An issue qualifies for a security + advisory when untrusted input (an ingested document, or a caller outside the + supported threat model) can reach data or files beyond what the API already + lets that caller touch — for example, writing outside the memory root. An + issue a trusted API caller can trigger only against data that same caller can + already modify or delete through the API is fixed as a hardening change and + noted in the release notes, without an advisory. diff --git a/src/everos/service/knowledge.py b/src/everos/service/knowledge.py index db3a0a770..d7a0d076f 100644 --- a/src/everos/service/knowledge.py +++ b/src/everos/service/knowledge.py @@ -42,7 +42,7 @@ TopicNotFoundError, ) from everos.core.observability.logging import get_logger -from everos.core.persistence import MemoryRoot +from everos.core.persistence import MemoryRoot, sanitize_dirname from everos.core.persistence.markdown import dump_frontmatter, parse_frontmatter from everos.infra.persistence.index import Predicate, all_of, eq from everos.infra.persistence.markdown import ( @@ -727,25 +727,24 @@ async def _update_index_frontmatter( await apath.write_text(dump_frontmatter(fm) + body, encoding="utf-8") -_DIR_SAFE = re.compile(r"[^\w\-.]", re.UNICODE) - - -def _safe_category(raw: str) -> str: - """Sanitize category_id for use as a directory name component.""" - slug = raw.replace(" ", "_") - slug = _DIR_SAFE.sub("", slug)[:50] - return slug or "Others" - - async def _move_doc_directory( memory_root: MemoryRoot, - old_md_path: str, + current: _ResolvedDoc, new_category: str, ) -> str: - """Move document directory to new category folder, return new md_path.""" - old_index = memory_root.root / old_md_path - old_dir = old_index.parent - new_dir = old_dir.parent.parent / _safe_category(new_category) / old_dir.name + """Move document directory to new category folder, return new md_path. + + The category becomes a directory segment through the same + ``sanitize_dirname`` rule the create path uses, so ``.``/``..`` fall back + to ``Others`` instead of walking out of ``knowledge/``. The resolved + target is then asserted to stay inside the project's knowledge directory + before any directory is created or moved. + """ + knowledge_dir = memory_root.knowledge_dir(current.app_id, current.project_id) + old_dir = (memory_root.root / current.md_path).parent + new_dir = knowledge_dir / sanitize_dirname(new_category, "Others") / old_dir.name + if not new_dir.resolve().is_relative_to(knowledge_dir.resolve()): + raise PathTraversalError(f"category move target escapes knowledge/: {new_dir}") await anyio.Path(new_dir.parent).mkdir(parents=True, exist_ok=True) await anyio.to_thread.run_sync(shutil.move, str(old_dir), str(new_dir)) new_index = new_dir / "index.md" @@ -843,9 +842,7 @@ async def _apply_patch_writes( await _update_index_frontmatter(index_path, new_title, new_category) if new_category != current.category_id: - new_md_path = await _move_doc_directory( - memory_root, current.md_path, new_category - ) + new_md_path = await _move_doc_directory(memory_root, current, new_category) new_doc_dir = memory_root.root / Path(new_md_path).parent await _update_topics_category(new_doc_dir, new_category) diff --git a/tests/unit/test_service/test_knowledge_crud.py b/tests/unit/test_service/test_knowledge_crud.py index 3ad583d62..5249e272b 100644 --- a/tests/unit/test_service/test_knowledge_crud.py +++ b/tests/unit/test_service/test_knowledge_crud.py @@ -16,6 +16,8 @@ import pytest from everos.component.utils.datetime import get_utc_now +from everos.core.errors import PathTraversalError +from everos.core.persistence import MemoryRoot from everos.infra.persistence.sqlite.repos.knowledge import DocumentListPage from everos.infra.persistence.sqlite.tables.knowledge import ( KnowledgeDocumentRow, @@ -398,3 +400,86 @@ async def test_patch_document_not_found_raises() -> None: with pytest.raises(DocumentNotFoundError): await patch_document("d_missing", "app1", "proj1", title="New") + + +# ── patch_document: category move containment ──────────────────────────────── + + +def _lay_out_doc(root: MemoryRoot, category: str) -> Path: + """Create ``knowledge//Doc_/`` on disk; return the doc dir.""" + doc_dir = root.knowledge_dir("app1", "proj1") / category / "Doc_d_testdoc00001" + doc_dir.mkdir(parents=True) + (doc_dir / "index.md").write_text("---\ntitle: Test Doc\n---\n") + (doc_dir / "1_intro.md").write_text("---\ncategory_id: Technology\n---\n") + return doc_dir + + +async def _patch_category(root: MemoryRoot, md_path: str, category_id: str) -> None: + doc = _doc_row(md_path=md_path) + with ( + patch(f"{_MOD}.MemoryRoot.resolve", return_value=root), + patch(f"{_MOD}.knowledge_document_repo") as mock_doc_repo, + ): + mock_doc_repo.get_by_doc_id = AsyncMock(return_value=doc) + mock_doc_repo.upsert_from_handler = AsyncMock(return_value=None) + await patch_document("d_testdoc00001", "app1", "proj1", category_id=category_id) + + +@pytest.mark.parametrize( + ("category_id", "expected_dir"), + [("Research Notes", "Research_Notes"), ("..", "Others"), (".", "Others")], +) +async def test_patch_document_category_move_stays_in_knowledge( + tmp_path: Path, category_id: str, expected_dir: str +) -> None: + """``.``/``..`` fall back to ``Others`` like the create path does.""" + root = MemoryRoot(tmp_path) + doc_dir = _lay_out_doc(root, "Technology") + md_path = str((doc_dir / "index.md").relative_to(root.root)) + + await _patch_category(root, md_path, category_id) + + knowledge_dir = root.knowledge_dir("app1", "proj1") + moved = knowledge_dir / expected_dir / "Doc_d_testdoc00001" + assert (moved / "index.md").is_file() + assert (moved / "1_intro.md").is_file() + assert not doc_dir.exists() + assert not (knowledge_dir.parent / "Doc_d_testdoc00001").exists() + + +async def test_patch_document_category_move_repairs_escaped_doc( + tmp_path: Path, +) -> None: + """A doc an earlier ``..`` move left outside ``knowledge/`` is moved back.""" + root = MemoryRoot(tmp_path) + knowledge_dir = root.knowledge_dir("app1", "proj1") + knowledge_dir.mkdir(parents=True) + escaped = knowledge_dir.parent / "Doc_d_testdoc00001" + escaped.mkdir() + (escaped / "index.md").write_text("---\ntitle: Test Doc\n---\n") + md_path = str( + knowledge_dir.relative_to(root.root) / ".." / escaped.name / "index.md" + ) + + await _patch_category(root, md_path, "Science") + + assert (knowledge_dir / "Science" / escaped.name / "index.md").is_file() + assert not escaped.exists() + + +async def test_patch_document_category_move_rejects_escaping_target( + tmp_path: Path, +) -> None: + """A category dir symlinked out of ``knowledge/`` trips the backstop.""" + root = MemoryRoot(tmp_path) + doc_dir = _lay_out_doc(root, "Technology") + outside = tmp_path / "outside" + outside.mkdir() + (root.knowledge_dir("app1", "proj1") / "Others").symlink_to(outside) + md_path = str((doc_dir / "index.md").relative_to(root.root)) + + with pytest.raises(PathTraversalError): + await _patch_category(root, md_path, "..") + + assert doc_dir.is_dir() + assert not any(outside.iterdir())