Skip to content

Commit e34e79b

Browse files
committed
fix: harden custom memory profiles
1 parent 7b5bc11 commit e34e79b

6 files changed

Lines changed: 128 additions & 13 deletions

File tree

plugins/hermes-dory/provider.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,10 +1429,7 @@ def _build_tool_schemas() -> list[dict[str, Any]]:
14291429
"budget_tokens": {"type": "integer"},
14301430
"agent": {"type": "string"},
14311431
"project": {"type": "string"},
1432-
"profile": {
1433-
"type": "string",
1434-
"enum": ["default", "casual", "coding", "writing", "privacy"],
1435-
},
1432+
"profile": {"type": "string"},
14361433
"include_recent_sessions": {"type": "integer"},
14371434
"include_pinned_decisions": {"type": "boolean"},
14381435
},
@@ -1460,10 +1457,7 @@ def _build_tool_schemas() -> list[dict[str, Any]]:
14601457
"until": {"type": "string"},
14611458
},
14621459
},
1463-
"profile": {
1464-
"type": "string",
1465-
"enum": ["auto", "general", "coding", "writing", "privacy", "personal"],
1466-
},
1460+
"profile": {"type": "string"},
14671461
"timeout_ms": {"type": "integer", "minimum": 100, "maximum": 5000},
14681462
"budget_tokens": {"type": "integer", "minimum": 100, "maximum": 1200},
14691463
"include_wake": {"type": "boolean"},

src/dory_core/active_memory.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ def allows_result_path(self, path: str, *, corpus: str) -> bool:
107107
def path_weight(self, path: str) -> float:
108108
return self.retrieval.path_weight(path)
109109

110+
def needs_prefilter_expansion(self, *, corpus: str) -> bool:
111+
if corpus == "sessions":
112+
return False
113+
return bool(self.retrieval.allow or self.retrieval.deny or not self.retrieval.include_durable_context)
114+
110115

111116
class _WakeBuilder(Protocol):
112117
def build(self, req: WakeReq) -> WakeResp: ...
@@ -511,13 +516,14 @@ def _search_candidates(
511516
source_policy: SourcePolicy | None = None,
512517
) -> list[object]:
513518
scored_results: dict[str, tuple[float, object]] = {}
519+
request_k = _expanded_candidate_limit(k, source_policy=source_policy, corpus=corpus)
514520
for query_index, query in enumerate(query for query in queries if query.strip()):
515521
if deadline is not None and deadline.expired:
516522
break
517523
response = search_engine.search(
518524
SearchReq(
519525
query=query,
520-
k=k,
526+
k=request_k,
521527
mode=mode,
522528
corpus=corpus,
523529
include_content=include_content,
@@ -529,6 +535,10 @@ def _search_candidates(
529535
path = _result_path(result)
530536
if not path:
531537
continue
538+
if not _is_active_memory_candidate(result, corpus=corpus):
539+
continue
540+
if source_policy is not None and not source_policy.allows_result_path(path, corpus=corpus):
541+
continue
532542
raw_score = float(getattr(result, "score", 0.0) or 0.0)
533543
rank_score = getattr(result, "rank_score", None)
534544
normalized_score = getattr(result, "score_normalized", None)
@@ -549,6 +559,12 @@ def _search_candidates(
549559
return [result for _score, result in ordered[:k]]
550560

551561

562+
def _expanded_candidate_limit(k: int, *, source_policy: SourcePolicy | None, corpus: str) -> int:
563+
if source_policy is None or not source_policy.needs_prefilter_expansion(corpus=corpus):
564+
return k
565+
return min(50, max(k, k * 4))
566+
567+
552568
def _preferred_active_memory_results(results: list[object]) -> list[object]:
553569
fresh_results = [result for result in results if not str(getattr(result, "stale_warning", "") or "").strip()]
554570
return fresh_results or results

src/dory_core/wake.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ def _load_hot_block_sections(self, *, profile: WakeProfile = "default", agent: s
7474
def _load_named_section(self, *, name: str, profile: WakeProfile, agent: str) -> HotBlockSection | None:
7575
if name == "privacy_boundaries":
7676
return self._load_privacy_boundaries_section(agent=agent)
77-
path = self.root / _resolve_wake_section_path(name)
77+
rel_path = _resolve_wake_section_path(name)
78+
if rel_path is None:
79+
return None
80+
path = self.root / rel_path
7881
return self._load_file_section(path, name=name, profile=profile, agent=agent)
7982

8083
def _load_file_section(
@@ -87,6 +90,8 @@ def _load_file_section(
8790
) -> HotBlockSection | None:
8891
if not path.exists():
8992
return None
93+
if not _is_within_root(path, self.root):
94+
return None
9095
content = path.read_text(encoding="utf-8").strip()
9196
return HotBlockSection(
9297
path=path.relative_to(self.root),
@@ -143,7 +148,8 @@ def _compact_profile_section(
143148
lines.append(line)
144149

145150
excerpt = "\n".join(lines).strip()
146-
section_path = _resolve_wake_section_path(name).as_posix()
151+
resolved_section_path = _resolve_wake_section_path(name)
152+
section_path = resolved_section_path.as_posix() if resolved_section_path is not None else name
147153
if name in _CORE_SECTION_NAMES:
148154
suffix = f"<!-- wake excerpt truncated; use dory_get('{section_path}') for the full file -->"
149155
else:
@@ -440,10 +446,21 @@ def _dedupe_preserve_order(items: list[str]) -> list[str]:
440446
return deduped
441447

442448

443-
def _resolve_wake_section_path(name: str) -> Path:
449+
def _resolve_wake_section_path(name: str) -> Path | None:
444450
if name in _CORE_SECTION_PATHS:
445451
return _CORE_SECTION_PATHS[name]
446-
return Path(name)
452+
path = Path(name)
453+
if path.is_absolute() or ".." in path.parts:
454+
return None
455+
return path
456+
457+
458+
def _is_within_root(path: Path, root: Path) -> bool:
459+
try:
460+
path.resolve().relative_to(root.resolve())
461+
except ValueError:
462+
return False
463+
return True
447464

448465

449466
def _section_budget_key(name: str) -> str:

tests/integration/core/test_wake_builder.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,26 @@ def test_wake_builder_loads_custom_profile_sections(tmp_path) -> None:
4444
assert "Dory maintenance" not in resp.block
4545

4646

47+
def test_wake_builder_rejects_custom_profile_sections_outside_corpus(tmp_path) -> None:
48+
outside = tmp_path.parent / f"{tmp_path.name}-outside.md"
49+
outside.write_text("Outside corpus context must not load.\n", encoding="utf-8")
50+
(tmp_path / "profiles.yaml").write_text(
51+
f"""
52+
profiles:
53+
unsafe:
54+
wake:
55+
sections:
56+
- ../{outside.name}
57+
""".strip(),
58+
encoding="utf-8",
59+
)
60+
61+
resp = WakeBuilder(tmp_path).build(WakeReq(agent="claude-code", profile="unsafe", budget_tokens=200))
62+
63+
assert resp.block == ""
64+
assert resp.sources == []
65+
66+
4767
def test_writing_profile_applies_writing_voice_budget(tmp_path) -> None:
4868
(tmp_path / "core").mkdir(parents=True)
4969
(tmp_path / "knowledge" / "personal").mkdir(parents=True)

tests/unit/test_active_memory.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,64 @@ def search(self, req: SearchReq): # pragma: no cover - test stub
772772
assert "Dory admin ops" not in result.block
773773

774774

775+
def test_active_memory_custom_profile_filters_before_top_k_truncation(tmp_path: Path) -> None:
776+
class WindowedSearchEngine:
777+
def __init__(self) -> None:
778+
self.requests: list[SearchReq] = []
779+
780+
def search(self, req: SearchReq): # pragma: no cover - test stub
781+
self.requests.append(req)
782+
disallowed = [
783+
_make_result(
784+
path=f"projects/dory/high-score-{index}.md",
785+
snippet=f"Disallowed project result {index}.",
786+
score=1.0 - (index * 0.01),
787+
confidence="high",
788+
)
789+
for index in range(10)
790+
]
791+
allowed = _make_result(
792+
path="profiles/brand/default.md",
793+
snippet="Allowed brand profile context survives filtering.",
794+
score=0.1,
795+
confidence="high",
796+
)
797+
return _make_response([*disallowed, allowed][: req.k])
798+
799+
(tmp_path / "profiles" / "brand").mkdir(parents=True)
800+
(tmp_path / "profiles" / "brand" / "default.md").write_text("Brand defaults.\n", encoding="utf-8")
801+
(tmp_path / "profiles.yaml").write_text(
802+
"""
803+
profiles:
804+
brand:
805+
retrieval:
806+
allow:
807+
- profiles/brand/**
808+
sessions: never
809+
""".strip(),
810+
encoding="utf-8",
811+
)
812+
search_engine = WindowedSearchEngine()
813+
engine = ActiveMemoryEngine(
814+
wake_builder=WakeBuilder(root=tmp_path),
815+
search_engine=search_engine,
816+
root=tmp_path,
817+
)
818+
819+
result = engine.build(
820+
ActiveMemoryReq(
821+
prompt="draft brand launch copy",
822+
agent="codex",
823+
profile="brand",
824+
include_wake=False,
825+
)
826+
)
827+
828+
assert search_engine.requests[0].k > 6
829+
assert "Allowed brand profile context survives filtering." in result.block
830+
assert "Disallowed project result" not in result.block
831+
832+
775833
def test_active_memory_uses_planner_queries_and_llm_composition(tmp_path: Path) -> None:
776834
search_engine = _StubSearchEngine()
777835
engine = ActiveMemoryEngine(

tests/unit/test_hermes_provider_config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,16 @@ def test_hermes_provider_tool_schema_exposes_finalized_dory_surface() -> None:
424424
]
425425

426426

427+
def test_hermes_fallback_tool_schema_accepts_custom_profile_names(monkeypatch) -> None:
428+
module = _load_provider_module()
429+
monkeypatch.setattr(module, "_build_canonical_hermes_tool_schemas", lambda: None)
430+
provider = module.DoryMemoryProvider(base_url="http://dory.local:8766")
431+
schemas = {schema["name"]: schema for schema in provider.get_tool_schemas()}
432+
433+
assert schemas["dory_wake"]["parameters"]["properties"]["profile"] == {"type": "string"}
434+
assert schemas["dory_active_memory"]["parameters"]["properties"]["profile"] == {"type": "string"}
435+
436+
427437
def test_hermes_publish_research_writes_knowledge_markdown_dry_run_by_default() -> None:
428438
module = _load_provider_module()
429439
captured: dict[str, object] = {}

0 commit comments

Comments
 (0)