Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
35 changes: 16 additions & 19 deletions src/everos/service/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
85 changes: 85 additions & 0 deletions tests/unit/test_service/test_knowledge_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<category>/Doc_<id>/`` 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())
Loading