feat(acl): unify the retrieval scopes; mark shared rows in Knowledge browse
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Build & push image (push) Has been skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Build & push image (push) Has been skipped
Closes #2092 and the Knowledge-browse provenance gap. The two halves of a hybrid search disagreed: the keyword half honoured shares while the semantic half was pinned to NoteEmbedding.user_id, so a shared record was findable by wording and invisible by meaning — the case a semantic search exists to serve. semantic_search_notes now scopes on Note via a `scope` parameter, and each of its five callers declares which kind of act it is: mcp/tools/search.py read the agent asked routes/search.py read the user typed it knowledge.py (semantic) read matches the keyword half beside it plugin_context.py browse nobody asked; never a one-to-one share dedup.py own a verdict that blocks a write must not hinge on another person's notes That last one is the reason this isn't a single global widening: the dedup gate returns "update the existing one instead", so matching a stranger's record would refuse a legitimate create and point at something the caller can't edit. Scope defaults to "own" so a caller that forgets is wrong in the safe direction, and an unknown scope raises rather than falling back — a typo there would be a data-exposure bug. Auto-inject keeps the browse scope, which still admits a collaborator's note via a shared project. Its menu line is the only provenance an agent sees, so a foreign hit now reads: #12 "Title" (0.71) - shared by alex, treat as a suggestion. MCP and REST search results carry shared/owner too. Knowledge browse: the feed hydrates cards from /api/knowledge/batch rather than the list route, so both paths label rows now, and KnowledgeView shows "by <owner>" on records the viewer doesn't own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
@@ -27,6 +27,10 @@ interface KnowledgeItem {
|
|||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
// Set only when another user owns this record — their suggestion, not one of
|
||||||
|
// yours. Absent means it's yours.
|
||||||
|
shared?: boolean;
|
||||||
|
owner?: string | null;
|
||||||
// Task-specific
|
// Task-specific
|
||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
@@ -437,6 +441,11 @@ onUnmounted(() => {
|
|||||||
<div class="k-card-tags">
|
<div class="k-card-tags">
|
||||||
<span v-for="tag in item.tags.slice(0, 3)" :key="tag" class="tag-pill">{{ tag }}</span>
|
<span v-for="tag in item.tags.slice(0, 3)" :key="tag" class="tag-pill">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="item.shared"
|
||||||
|
class="shared-tag"
|
||||||
|
:title="`Shared by ${item.owner ?? 'another user'} — their record, not yours`"
|
||||||
|
>by {{ item.owner ?? "another user" }}</span>
|
||||||
<span class="k-card-date">{{ formatDate(item.updated_at) }}</span>
|
<span class="k-card-date">{{ formatDate(item.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -839,6 +848,16 @@ onUnmounted(() => {
|
|||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
||||||
|
/* Only rendered for a record another user owns, so an unmarked card is
|
||||||
|
unambiguously the viewer's own. */
|
||||||
|
.shared-tag {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.08rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Task card ──────────────────────────────────────────── */
|
/* ── Task card ──────────────────────────────────────────── */
|
||||||
.k-card-task {
|
.k-card-task {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
|
from scribe.services.access import owner_names_for
|
||||||
from scribe.services.embeddings import DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes
|
from scribe.services.embeddings import DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
from scribe.services.retrieval_telemetry import record_retrieval
|
||||||
|
|
||||||
@@ -42,6 +43,10 @@ async def search(
|
|||||||
Returns:
|
Returns:
|
||||||
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
||||||
"total": int}
|
"total": int}
|
||||||
|
|
||||||
|
A result marked `shared: true` with an `owner` belongs to another user —
|
||||||
|
that person's suggestion, not the operator's own record or settled practice.
|
||||||
|
Weigh it on its merits and say whose it is when you use it.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
limit = max(1, min(limit, 50))
|
limit = max(1, min(limit, 50))
|
||||||
@@ -50,6 +55,9 @@ async def search(
|
|||||||
raw = await semantic_search_notes(
|
raw = await semantic_search_notes(
|
||||||
uid, q, limit=limit, is_task=is_task,
|
uid, q, limit=limit, is_task=is_task,
|
||||||
project_id=project_id or None,
|
project_id=project_id or None,
|
||||||
|
# An explicit search reaches everything the operator may read, including
|
||||||
|
# records shared with them one-to-one.
|
||||||
|
scope="read",
|
||||||
)
|
)
|
||||||
record_retrieval(
|
record_retrieval(
|
||||||
user_id=uid, source="mcp_search", query=q,
|
user_id=uid, source="mcp_search", query=q,
|
||||||
@@ -57,6 +65,9 @@ async def search(
|
|||||||
project_id=project_id or None, is_task=is_task, results=raw,
|
project_id=project_id or None, is_task=is_task, results=raw,
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
)
|
)
|
||||||
|
owners = await owner_names_for(
|
||||||
|
{int(note.user_id) for _s, note in raw if note.user_id != uid}
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
@@ -66,6 +77,10 @@ async def search(
|
|||||||
"is_task": bool(note.is_task),
|
"is_task": bool(note.is_task),
|
||||||
"tags": list(note.tags or []),
|
"tags": list(note.tags or []),
|
||||||
"similarity": float(score),
|
"similarity": float(score),
|
||||||
|
**(
|
||||||
|
{"shared": True, "owner": owners.get(int(note.user_id))}
|
||||||
|
if note.user_id != uid else {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for score, note in raw
|
for score, note in raw
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from quart import Blueprint, jsonify, request
|
|||||||
|
|
||||||
from scribe.auth import get_current_user_id, login_required
|
from scribe.auth import get_current_user_id, login_required
|
||||||
from scribe.routes.utils import parse_pagination
|
from scribe.routes.utils import parse_pagination
|
||||||
|
from scribe.services.access import label_shared_items
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -54,7 +55,9 @@ async def list_knowledge():
|
|||||||
)
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"items": items,
|
# Mark rows another user owns: this feed can be mixed-ownership, and an
|
||||||
|
# unmarked card reads as one the viewer wrote.
|
||||||
|
"items": await label_shared_items(uid, items),
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": limit,
|
"per_page": limit,
|
||||||
@@ -116,7 +119,9 @@ async def get_knowledge_batch():
|
|||||||
|
|
||||||
from scribe.services.knowledge import get_knowledge_by_ids
|
from scribe.services.knowledge import get_knowledge_by_ids
|
||||||
items = await get_knowledge_by_ids(uid, ids)
|
items = await get_knowledge_by_ids(uid, ids)
|
||||||
return jsonify({"items": items})
|
# The scrolling feed hydrates its cards here, not from the list route, so the
|
||||||
|
# ownership markers have to be applied on this path too.
|
||||||
|
return jsonify({"items": await label_shared_items(uid, items)})
|
||||||
|
|
||||||
|
|
||||||
@knowledge_bp.route("/tags", methods=["GET"])
|
@knowledge_bp.route("/tags", methods=["GET"])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import time
|
|||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from scribe.auth import login_required, get_current_user_id
|
from scribe.auth import login_required, get_current_user_id
|
||||||
|
from scribe.services.access import owner_names_for
|
||||||
from scribe.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
from scribe.services.retrieval_telemetry import record_retrieval
|
||||||
|
|
||||||
@@ -36,7 +37,9 @@ async def search_route():
|
|||||||
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
results = await semantic_search_notes(
|
results = await semantic_search_notes(
|
||||||
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD
|
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
|
||||||
|
# The user typed this, so it reaches everything they may read.
|
||||||
|
scope="read",
|
||||||
)
|
)
|
||||||
record_retrieval(
|
record_retrieval(
|
||||||
user_id=uid, source="rest_search", query=q,
|
user_id=uid, source="rest_search", query=q,
|
||||||
@@ -44,6 +47,9 @@ async def search_route():
|
|||||||
project_id=None, is_task=is_task, results=results,
|
project_id=None, is_task=is_task, results=results,
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
)
|
)
|
||||||
|
owners = await owner_names_for(
|
||||||
|
{int(note.user_id) for _s, note in results if note.user_id != uid}
|
||||||
|
)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
@@ -53,6 +59,10 @@ async def search_route():
|
|||||||
"is_task": note.is_task,
|
"is_task": note.is_task,
|
||||||
"tags": note.tags or [],
|
"tags": note.tags or [],
|
||||||
"similarity": score,
|
"similarity": score,
|
||||||
|
**(
|
||||||
|
{"shared": True, "owner": owners.get(int(note.user_id))}
|
||||||
|
if note.user_id != uid else {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for score, note in results # semantic_search_notes returns list[tuple[float, Note]]
|
for score, note in results # semantic_search_notes returns list[tuple[float, Note]]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -182,6 +182,45 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
|
|||||||
# you has not earned that standing, so it waits until you go looking for it.
|
# you has not earned that standing, so it waits until you go looking for it.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def notes_visibility_clause(user_id: int, scope: str = "own"):
|
||||||
|
"""The one place a retrieval declares how far it may see.
|
||||||
|
|
||||||
|
Every path that returns notes picks a scope by what KIND of act it is:
|
||||||
|
|
||||||
|
"own" — the caller's records only. For machinery whose answer must not
|
||||||
|
depend on other people: the near-duplicate gate can't block a
|
||||||
|
create because a stranger wrote something similar, and can't
|
||||||
|
point at a record the caller cannot edit.
|
||||||
|
"browse" — own + project-reachable. For passive surfaces, where an
|
||||||
|
unrequested record would read as endorsed.
|
||||||
|
"read" — everything the ACL permits. For explicit acts: a typed search,
|
||||||
|
a fetch by id.
|
||||||
|
|
||||||
|
Defaults to the narrowest, so a new caller that forgets to choose is wrong in
|
||||||
|
the safe direction.
|
||||||
|
"""
|
||||||
|
if scope == "own":
|
||||||
|
return Note.user_id == user_id
|
||||||
|
if scope == "browse":
|
||||||
|
return browsable_notes_clause(user_id)
|
||||||
|
if scope == "read":
|
||||||
|
return readable_notes_clause(user_id)
|
||||||
|
raise ValueError(f"unknown note scope {scope!r} (own | browse | read)")
|
||||||
|
|
||||||
|
|
||||||
|
async def owner_names_for(user_ids: set[int]) -> dict[int, str]:
|
||||||
|
"""{user_id: username} in one query. Empty input costs nothing."""
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(User.id, User.username).where(User.id.in_(user_ids))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {uid: name for uid, name in rows}
|
||||||
|
|
||||||
|
|
||||||
def _my_group_ids(user_id: int):
|
def _my_group_ids(user_id: int):
|
||||||
"""The caller's group ids as a SUBQUERY, not a fetched list.
|
"""The caller's group ids as a SUBQUERY, not a fetched list.
|
||||||
|
|
||||||
@@ -267,13 +306,7 @@ async def label_shared_items(user_id: int, items: list[dict]) -> list[dict]:
|
|||||||
}
|
}
|
||||||
if not foreign:
|
if not foreign:
|
||||||
return items
|
return items
|
||||||
async with async_session() as session:
|
names = await owner_names_for(foreign)
|
||||||
rows = (
|
|
||||||
await session.execute(
|
|
||||||
select(User.id, User.username).where(User.id.in_(foreign))
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
names = {uid: name for uid, name in rows}
|
|
||||||
for it in items:
|
for it in items:
|
||||||
owner_id = it.get("user_id")
|
owner_id = it.get("user_id")
|
||||||
if owner_id is not None and owner_id != user_id:
|
if owner_id is not None and owner_id != user_id:
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ async def find_duplicate_note(
|
|||||||
user_id, query, project_id=project_id, is_task=is_task,
|
user_id, query, project_id=project_id, is_task=is_task,
|
||||||
orphan_only=(project_id is None),
|
orphan_only=(project_id is None),
|
||||||
limit=3, threshold=_SEMANTIC_THRESHOLD,
|
limit=3, threshold=_SEMANTIC_THRESHOLD,
|
||||||
|
# Owner-only, deliberately: this gate BLOCKS a create and tells the
|
||||||
|
# caller to update the match instead. Matching someone else's record
|
||||||
|
# would refuse their write and point them at something they may not
|
||||||
|
# be able to edit.
|
||||||
|
scope="own",
|
||||||
)
|
)
|
||||||
for score, note in hits:
|
for score, note in hits:
|
||||||
# semantic_search_notes doesn't filter note_type — enforce it here so
|
# semantic_search_notes doesn't filter note_type — enforce it here so
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy import delete, select
|
|||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.embedding import NoteEmbedding
|
from scribe.models.embedding import NoteEmbedding
|
||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
|
from scribe.services.access import notes_visibility_clause
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -114,12 +115,21 @@ async def semantic_search_notes(
|
|||||||
project_id: int | None = None,
|
project_id: int | None = None,
|
||||||
is_task: bool | None = None,
|
is_task: bool | None = None,
|
||||||
orphan_only: bool = False,
|
orphan_only: bool = False,
|
||||||
|
scope: str = "own",
|
||||||
) -> list[tuple[float, Note]]:
|
) -> list[tuple[float, Note]]:
|
||||||
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
||||||
|
|
||||||
Scores are cosine similarities in [-1, 1]; only notes at or above
|
Scores are cosine similarities in [-1, 1]; only notes at or above
|
||||||
*threshold* are returned, sorted highest-first.
|
*threshold* are returned, sorted highest-first.
|
||||||
|
|
||||||
|
`scope` ("own" | "browse" | "read", see access.notes_visibility_clause)
|
||||||
|
decides how far this may see. It exists because this one function serves
|
||||||
|
three different kinds of act: an explicit search, which should reach
|
||||||
|
everything the caller may read; passive auto-injection, which must not pull
|
||||||
|
an unrequested record into their context; and the near-duplicate gate, whose
|
||||||
|
verdict must not depend on other people's notes at all. Defaults to "own" so
|
||||||
|
a caller that forgets is wrong in the safe direction.
|
||||||
|
|
||||||
Ranking and the top-k cut happen in Postgres via pgvector's cosine-distance
|
Ranking and the top-k cut happen in Postgres via pgvector's cosine-distance
|
||||||
operator (`<=>`, exposed as ``Vector.cosine_distance``) backed by the HNSW
|
operator (`<=>`, exposed as ``Vector.cosine_distance``) backed by the HNSW
|
||||||
index from migration 0067 — so this is an indexed ``ORDER BY ... LIMIT k``
|
index from migration 0067 — so this is an indexed ``ORDER BY ... LIMIT k``
|
||||||
@@ -145,11 +155,17 @@ async def semantic_search_notes(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
# Scope on Note, not NoteEmbedding.user_id: the embedding row belongs
|
||||||
|
# to the note's owner, so filtering it would pin every scope to "own"
|
||||||
|
# and leave shared records unreachable by meaning.
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Note, distance.label("distance"))
|
select(Note, distance.label("distance"))
|
||||||
.select_from(NoteEmbedding)
|
.select_from(NoteEmbedding)
|
||||||
.join(Note, NoteEmbedding.note_id == Note.id)
|
.join(Note, NoteEmbedding.note_id == Note.id)
|
||||||
.where(NoteEmbedding.user_id == user_id, Note.deleted_at.is_(None))
|
.where(
|
||||||
|
notes_visibility_clause(user_id, scope),
|
||||||
|
Note.deleted_at.is_(None),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if orphan_only:
|
if orphan_only:
|
||||||
stmt = stmt.where(Note.project_id.is_(None))
|
stmt = stmt.where(Note.project_id.is_(None))
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ ACL (rules #47/#78, decision note 2094): these queries were owner-only until
|
|||||||
*found*. They now honour shares — at two different widths:
|
*found*. They now honour shares — at two different widths:
|
||||||
|
|
||||||
- **searching** (a `q` the caller typed) uses the full read scope, so a record
|
- **searching** (a `q` the caller typed) uses the full read scope, so a record
|
||||||
shared directly with you is findable when you go looking for it;
|
shared directly with you is findable when you go looking for it. BOTH halves
|
||||||
|
of the hybrid search — keyword and semantic — see equally, or a record would
|
||||||
|
be findable by wording and invisible by meaning;
|
||||||
- **browsing** (no `q`) and the facet counts beside it use the narrower browse
|
- **browsing** (no `q`) and the facet counts beside it use the narrower browse
|
||||||
scope: your own records plus anything in a project you can reach.
|
scope: your own records plus anything in a project you can reach.
|
||||||
|
|
||||||
@@ -174,19 +176,17 @@ async def _semantic_knowledge_search(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Keyword search failed", exc_info=True)
|
logger.warning("Keyword search failed", exc_info=True)
|
||||||
|
|
||||||
# 2. Semantic search — conceptual similarity.
|
# 2. Semantic search — conceptual similarity, at the SAME scope as the
|
||||||
# NOTE: this half is still OWNER-ONLY. `semantic_search_notes` scopes by
|
# keyword half above. Both halves of one search must see equally, or a shared
|
||||||
# `NoteEmbedding.user_id`, and it also backs auto-inject and the `search`
|
# record would be findable by wording and invisible by meaning — which is the
|
||||||
# MCP tool — so widening it would put another user's shared content into
|
# case a semantic search exists to serve.
|
||||||
# your agent context automatically. That's a product decision, not a bug
|
|
||||||
# fix, and it's tracked separately. Consequence until then: a shared record
|
|
||||||
# is findable here by WORDING (the keyword half above) but not by MEANING.
|
|
||||||
semantic_notes: list[Note] = []
|
semantic_notes: list[Note] = []
|
||||||
try:
|
try:
|
||||||
from scribe.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes
|
||||||
is_task_filter = True if note_type in ("task", "plan") else (False if note_type else None)
|
is_task_filter = True if note_type in ("task", "plan") else (False if note_type else None)
|
||||||
candidates = await semantic_search_notes(
|
candidates = await semantic_search_notes(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
scope="read",
|
||||||
query=q,
|
query=q,
|
||||||
limit=min(200, limit * 4),
|
limit=min(200, limit * 4),
|
||||||
threshold=0.3,
|
threshold=0.3,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from scribe.services import knowledge as knowledge_svc
|
|||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from scribe.services.access import label_shared_items
|
from scribe.services.access import label_shared_items, owner_names_for
|
||||||
from scribe.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
from scribe.services.retrieval_telemetry import record_retrieval
|
||||||
from scribe.services.settings import get_setting
|
from scribe.services.settings import get_setting
|
||||||
@@ -196,6 +196,11 @@ async def build_autoinject_hint(
|
|||||||
threshold=cfg["threshold"],
|
threshold=cfg["threshold"],
|
||||||
project_id=(project_id or None),
|
project_id=(project_id or None),
|
||||||
exclude_ids=set(exclude_ids or []),
|
exclude_ids=set(exclude_ids or []),
|
||||||
|
# Injection is the one retrieval nobody asked for, so it takes the BROWSE
|
||||||
|
# scope: never a record shared one-to-one with the operator. What can
|
||||||
|
# still appear is a collaborator's note inside a shared project — legible
|
||||||
|
# only because the line below names its owner.
|
||||||
|
scope="browse",
|
||||||
)
|
)
|
||||||
record_retrieval(
|
record_retrieval(
|
||||||
user_id=user_id, source="auto_inject", query=q,
|
user_id=user_id, source="auto_inject", query=q,
|
||||||
@@ -210,6 +215,13 @@ async def build_autoinject_hint(
|
|||||||
top_score = hits[0][0]
|
top_score = hits[0][0]
|
||||||
kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND]
|
kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND]
|
||||||
|
|
||||||
|
# A collaborator's note can reach this menu via a shared project, and the
|
||||||
|
# operator never asked for it — so say whose it is. Unattributed, it reads as
|
||||||
|
# something they wrote and settled.
|
||||||
|
owners = await owner_names_for({
|
||||||
|
int(n.user_id) for _s, n in kept if n.user_id != user_id
|
||||||
|
})
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"> Possibly relevant from your Scribe notes — call `get_note(id)` to "
|
"> Possibly relevant from your Scribe notes — call `get_note(id)` to "
|
||||||
"open any in full (titles only; injected once per session):",
|
"open any in full (titles only; injected once per session):",
|
||||||
@@ -218,7 +230,11 @@ async def build_autoinject_hint(
|
|||||||
for score, note in kept:
|
for score, note in kept:
|
||||||
note_ids.append(int(note.id))
|
note_ids.append(int(note.id))
|
||||||
title = (note.title or "(untitled)").replace("\n", " ").strip()
|
title = (note.title or "(untitled)").replace("\n", " ").strip()
|
||||||
lines.append(f"> - #{note.id} \"{title}\" ({score:.2f})")
|
line = f"> - #{note.id} \"{title}\" ({score:.2f})"
|
||||||
|
if note.user_id != user_id:
|
||||||
|
who = owners.get(int(note.user_id)) or "another user"
|
||||||
|
line += f" — shared by {who}, treat as a suggestion"
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}
|
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Every retrieval path must declare the right visibility scope.
|
||||||
|
|
||||||
|
`semantic_search_notes` serves three different kinds of act, and the correct
|
||||||
|
scope differs for each. Getting one wrong is silent — the code still works, it
|
||||||
|
just sees too much or too little — so each caller is pinned here:
|
||||||
|
|
||||||
|
explicit search → "read" the caller asked; reach everything they may read
|
||||||
|
auto-injection → "browse" nobody asked; never a one-to-one shared record
|
||||||
|
near-duplicate → "own" a verdict that blocks a write can't hinge on
|
||||||
|
someone else's notes
|
||||||
|
|
||||||
|
The keyword and semantic halves of one hybrid search must also agree, or a
|
||||||
|
shared record would be findable by wording and invisible by meaning.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _note(id=1, user_id=7, title="A note"):
|
||||||
|
n = MagicMock()
|
||||||
|
n.id = id
|
||||||
|
n.user_id = user_id
|
||||||
|
n.title = title
|
||||||
|
n.body = "body"
|
||||||
|
n.tags = []
|
||||||
|
n.is_task = False
|
||||||
|
n.note_type = "note"
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def spy():
|
||||||
|
"""Patch semantic_search_notes everywhere it's imported and capture kwargs."""
|
||||||
|
mock = AsyncMock(return_value=[])
|
||||||
|
targets = [
|
||||||
|
"scribe.services.embeddings.semantic_search_notes",
|
||||||
|
"scribe.services.plugin_context.semantic_search_notes",
|
||||||
|
"scribe.mcp.tools.search.semantic_search_notes",
|
||||||
|
"scribe.routes.search.semantic_search_notes",
|
||||||
|
]
|
||||||
|
patches = [patch(t, mock) for t in targets]
|
||||||
|
for p in patches:
|
||||||
|
p.start()
|
||||||
|
yield mock
|
||||||
|
for p in patches:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_search_uses_read_scope(spy):
|
||||||
|
from scribe.mcp._context import _user_id_ctx
|
||||||
|
token = _user_id_ctx.set(7)
|
||||||
|
try:
|
||||||
|
with patch("scribe.services.retrieval_telemetry.record_retrieval", MagicMock()):
|
||||||
|
from scribe.mcp.tools.search import search
|
||||||
|
await search(q="debounce")
|
||||||
|
finally:
|
||||||
|
_user_id_ctx.reset(token)
|
||||||
|
assert spy.await_args.kwargs["scope"] == "read"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_inject_uses_browse_scope(spy):
|
||||||
|
"""The one retrieval nobody requested. A record shared one-to-one with the
|
||||||
|
operator must never arrive this way."""
|
||||||
|
from scribe.services import plugin_context
|
||||||
|
with patch.object(plugin_context, "get_autoinject_config",
|
||||||
|
AsyncMock(return_value={"enabled": True, "threshold": 0.55,
|
||||||
|
"top_k": 5})), \
|
||||||
|
patch.object(plugin_context, "record_retrieval", MagicMock()):
|
||||||
|
await plugin_context.build_autoinject_hint(7, "how do I debounce")
|
||||||
|
assert spy.await_args.kwargs["scope"] == "browse"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dedup_gate_uses_own_scope(spy):
|
||||||
|
"""An empty title skips the title signal and goes straight to the semantic
|
||||||
|
one — no session needed. The gate blocks a create and tells the caller to
|
||||||
|
update the match instead, so matching another user's record would refuse
|
||||||
|
their write and point them at something they may not be able to edit."""
|
||||||
|
from scribe.services import dedup
|
||||||
|
await dedup.find_duplicate_note(
|
||||||
|
7, "", body="x" * 400, project_id=None, is_task=False,
|
||||||
|
)
|
||||||
|
assert spy.await_args.kwargs["scope"] == "own"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hybrid_search_halves_agree_on_scope():
|
||||||
|
"""The keyword half and the semantic half of one search must see equally."""
|
||||||
|
from scribe.services import knowledge
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_semantic(**kwargs):
|
||||||
|
captured["scope"] = kwargs.get("scope")
|
||||||
|
return []
|
||||||
|
|
||||||
|
from scribe.models.note import Note
|
||||||
|
with patch("scribe.services.embeddings.semantic_search_notes",
|
||||||
|
AsyncMock(side_effect=fake_semantic)), \
|
||||||
|
patch.object(knowledge, "readable_notes_clause",
|
||||||
|
MagicMock(return_value=(Note.user_id == 7))) as read_clause, \
|
||||||
|
patch.object(knowledge, "async_session") as sess:
|
||||||
|
session = AsyncMock()
|
||||||
|
session.__aenter__ = AsyncMock(return_value=session)
|
||||||
|
session.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
result = MagicMock()
|
||||||
|
result.scalars.return_value.all.return_value = []
|
||||||
|
session.execute = AsyncMock(return_value=result)
|
||||||
|
sess.return_value = session
|
||||||
|
|
||||||
|
await knowledge.query_knowledge(
|
||||||
|
user_id=7, note_type=None, tags=[], sort="modified",
|
||||||
|
q="debounce", limit=10, offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keyword half took the read scope...
|
||||||
|
read_clause.assert_called_once_with(7)
|
||||||
|
# ...and so did the semantic half.
|
||||||
|
assert captured["scope"] == "read"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_injected_menu_attributes_another_users_note():
|
||||||
|
"""Auto-inject can surface a collaborator's note via a shared project. The
|
||||||
|
menu line is the only provenance the agent sees, so it has to name them."""
|
||||||
|
from scribe.services import plugin_context
|
||||||
|
|
||||||
|
mine, theirs = _note(1, user_id=7, title="Mine"), _note(2, user_id=9, title="Theirs")
|
||||||
|
with patch.object(plugin_context, "semantic_search_notes",
|
||||||
|
AsyncMock(return_value=[(0.9, theirs), (0.88, mine)])), \
|
||||||
|
patch.object(plugin_context, "get_autoinject_config",
|
||||||
|
AsyncMock(return_value={"enabled": True, "threshold": 0.5,
|
||||||
|
"top_k": 5})), \
|
||||||
|
patch.object(plugin_context, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(plugin_context, "owner_names_for",
|
||||||
|
AsyncMock(return_value={9: "alex"})):
|
||||||
|
out = await plugin_context.build_autoinject_hint(7, "anything")
|
||||||
|
|
||||||
|
lines = out["context"].splitlines()
|
||||||
|
theirs_line = next(ln for ln in lines if "#2" in ln)
|
||||||
|
mine_line = next(ln for ln in lines if "#1" in ln)
|
||||||
|
assert "shared by alex" in theirs_line
|
||||||
|
assert "suggestion" in theirs_line
|
||||||
|
# The operator's own note stays unadorned — absence of a marker is the signal.
|
||||||
|
assert "shared by" not in mine_line
|
||||||
@@ -15,7 +15,11 @@ Assertions compile each clause to SQL and inspect its shape.
|
|||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from scribe.services.access import browsable_notes_clause, readable_notes_clause
|
from scribe.services.access import (
|
||||||
|
browsable_notes_clause,
|
||||||
|
notes_visibility_clause,
|
||||||
|
readable_notes_clause,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sql(clause) -> str:
|
def _sql(clause) -> str:
|
||||||
@@ -92,6 +96,32 @@ def test_browse_scope_is_never_the_whole_table():
|
|||||||
assert "1 = 1" not in sql
|
assert "1 = 1" not in sql
|
||||||
|
|
||||||
|
|
||||||
|
# --- the scope resolver ------------------------------------------------------
|
||||||
|
|
||||||
|
def test_own_scope_is_ownership_alone():
|
||||||
|
"""The near-duplicate gate depends on this: its verdict must not turn on
|
||||||
|
another person's records, or it would refuse a write and point the caller at
|
||||||
|
something they may not be able to edit."""
|
||||||
|
sql = _sql(notes_visibility_clause(7, "own"))
|
||||||
|
assert sql == "notes.user_id = 7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scope_resolver_maps_to_the_right_clauses():
|
||||||
|
assert _sql(notes_visibility_clause(7, "browse")) == _browse()
|
||||||
|
assert _sql(notes_visibility_clause(7, "read")) == _read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scope_defaults_to_the_narrowest():
|
||||||
|
"""A caller that forgets to choose must be wrong in the safe direction."""
|
||||||
|
assert _sql(notes_visibility_clause(7)) == _sql(notes_visibility_clause(7, "own"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_scope_is_rejected_loudly():
|
||||||
|
"""Silently falling back would turn a typo into a data-exposure bug."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
notes_visibility_clause(7, "everything")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("clause_fn", [readable_notes_clause, browsable_notes_clause])
|
@pytest.mark.parametrize("clause_fn", [readable_notes_clause, browsable_notes_clause])
|
||||||
def test_clauses_are_pure_builders(clause_fn):
|
def test_clauses_are_pure_builders(clause_fn):
|
||||||
"""Synchronous and side-effect free — no coroutine, no session of their own.
|
"""Synchronous and side-effect free — no coroutine, no session of their own.
|
||||||
|
|||||||
Reference in New Issue
Block a user