feat(ws5): memory/handbook injection seams (context-provider pattern) #43

Merged
amoussa1229 merged 3 commits from feat/ws5-memory-handbook-seams into main 2026-06-23 19:48:14 +00:00
2 changed files with 57 additions and 5 deletions
Showing only changes of commit 8fb7d188b3 - Show all commits

View file

@ -57,6 +57,36 @@ def test_save_memory_rejects_path_traversal(tmp_path: Path) -> None:
save_memory("", "bad", memory_dir=tmp_path)
def test_save_memory_rejects_unsafe_allowlist_names(tmp_path: Path) -> None:
from retriever import save_memory
# Allowlist rejects dot-only / hidden / backslash / NUL / over-long names
# that the old blocklist let through as malformed-but-contained files.
for bad in (".", "..", ".hidden", "a\\b", "a\x00b", "x" * 200, "-leading"):
with pytest.raises(ValueError):
save_memory(bad, "bad", memory_dir=tmp_path)
def test_save_memory_does_not_follow_symlink_out_of_drafts(tmp_path: Path) -> None:
import os
from retriever import save_memory
drafts = tmp_path / "_box-drafts"
drafts.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("original")
# Pre-plant a symlink in the drafts dir pointing outside it.
(drafts / "evil.md").symlink_to(outside)
# O_NOFOLLOW must refuse to write through the symlink (ELOOP).
with pytest.raises(OSError):
save_memory("evil", "overwrite attempt", memory_dir=tmp_path)
# The outside target is untouched.
assert outside.read_text() == "original"
assert os.path.islink(drafts / "evil.md")
def test_save_memory_does_not_appear_in_live_dir(tmp_path: Path) -> None:
from retriever import load_memories, save_memory

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import json
import math
import os
import re
from dataclasses import dataclass
from pathlib import Path
@ -160,6 +161,13 @@ def retrieve(
# so the retriever never auto-indexes a not-yet-reviewed draft.
_DRAFTS_SUBDIR = "_box-drafts"
# Allowlist for save_memory() names (defense-in-depth over the old blocklist).
# Must start alphanumeric, then alphanumerics / dot / underscore / hyphen. This
# rejects path separators, NUL, leading-dot hidden files, and dot-only names
# (".", "..") outright — a name cannot escape the drafts dir or be malformed.
_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_MAX_NAME_LEN = 128
def save_memory(name: str, content: str, *, memory_dir: Path | None = None) -> Path:
"""Write a memory draft to the review queue (``_box-drafts/`` subdir).
@ -170,17 +178,31 @@ def save_memory(name: str, content: str, *, memory_dir: Path | None = None) -> P
never indexes ``_box-drafts/`` entries, so writing here never
auto-activates a draft.
``memory_dir`` defaults to :data:`MEMORY_DIR`. ``name`` must be a plain
filename stem (no path separators or null bytes) to prevent traversal.
Raises ``ValueError`` on unsafe names. Creates the subdir if absent.
``memory_dir`` defaults to :data:`MEMORY_DIR`. ``name`` must match the safe
allowlist (alphanumeric start; then ``A-Z a-z 0-9 . _ -``; max 128 chars) so
it cannot contain path separators, NUL, ``..`` traversal, or be a dot-only /
hidden name. Raises ``ValueError`` on unsafe names. Creates the subdir if
absent.
"""
if not name or any(c in name for c in ("/", "\\", "\x00", "..")):
if (
not name
or len(name) > _MAX_NAME_LEN
or ".." in name
or not _SAFE_NAME_RE.match(name)
):
raise ValueError(f"unsafe memory name: {name!r}")
effective_dir = memory_dir if memory_dir is not None else MEMORY_DIR
drafts_dir = effective_dir / _DRAFTS_SUBDIR
drafts_dir.mkdir(parents=True, exist_ok=True)
dest = drafts_dir / f"{name}.md"
dest.write_text(content)
# Symlink-safe write: O_NOFOLLOW makes the open fail (ELOOP) if the final
# path component is a pre-planted symlink, closing the TOCTOU where a symlink
# in _box-drafts/ could redirect the write outside the dir. O_CREAT|O_TRUNC
# preserves the overwrite-on-resave behavior for a regular file.
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW
fd = os.open(dest, flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
return dest