From 8cfd7e986dec8fb3addd00a2ebca6e4a62ac1da0 Mon Sep 17 00:00:00 2001 From: Dani Date: Mon, 28 Sep 2026 16:27:50 -0400 Subject: [PATCH 1/4] fix(knowledge): keep PATCH category moves inside knowledge/ PATCH /knowledge/documents/{doc_id} sanitized the new category with a private copy of the dirname rule that lacked the "." / ".." fallback, so category_id ".." moved the document directory out of knowledge/ into the project directory, where the md scan no longer finds it. Route the category through the shared sanitize_dirname (the rule the create path already uses, so "." / ".." fall back to "Others"), build the target from the project's knowledge_dir, and assert the resolved target stays inside it before any directory is created or moved. A document an earlier move left outside knowledge/ is moved back on its next category change. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/everos/service/knowledge.py | 35 ++++---- .../unit/test_service/test_knowledge_crud.py | 85 +++++++++++++++++++ 2 files changed, 101 insertions(+), 19 deletions(-) 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()) From e0944256dd14de8c70568ac2b0d9011f4006735c Mon Sep 17 00:00:00 2001 From: Dani Date: Mon, 28 Sep 2026 16:28:05 -0400 Subject: [PATCH 2/4] docs(security): refresh supported versions and define advisory scope The supported-versions table still named 1.2.x as current; 1.4.x is the live line. Also state where the advisory line sits: issues that let untrusted input reach beyond what the caller can already touch get an advisory, while issues a trusted caller can trigger only against its own data are fixed as hardening and noted in the release notes. Co-Authored-By: Claude Opus 5.5 (1M context) --- SECURITY.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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. From 32da203e21f48d7a766dc5a659c18694ac32099e Mon Sep 17 00:00:00 2001 From: Dani Date: Mon, 28 Sep 2026 17:34:24 -0400 Subject: [PATCH 3/4] docs(changelog): note the knowledge category move fix Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8faa88d95..8866fffb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ 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. + ## [1.4.1] - 2026-09-24 **A fresh install works again.** The `openai` SDK released its 3.x line on From 4bf8bc393aa5e3fc4a57234ed93437d9de4b47e6 Mon Sep 17 00:00:00 2001 From: Dani Date: Mon, 28 Sep 2026 21:35:58 -0400 Subject: [PATCH 4/4] docs(changelog): credit the reporter of the category move fix Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8866fffb3..32c67efc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. + left outside is moved back on its next category change. Reported by + White0xdi3. ## [1.4.1] - 2026-09-24