feat(acl): shared records are search-only and always labelled as someone else's
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 28s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Failing after 41s
CI & Build / Build & push image (push) Has been skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 28s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Failing after 41s
CI & Build / Build & push image (push) Has been skipped
Narrows theb7d6fc7widening per the operator's call, and fixes a regression it introduced. Decision recorded as note 2094. Two scopes now, deliberately different: readable_notes_clause — everything the ACL permits, including records reached only via a direct/group note share. For EXPLICIT acts: a search the caller typed, a fetch by id. browsable_notes_clause — the caller's own records plus anything in a project they can reach. For PASSIVE surfaces: browse lists, facet counts, the process->skill manifest. The split is a trust boundary. Anything appearing unasked — in your own list, your own counts, or as a skill installed on your machine — reads as material you endorsed. A one-off someone shared with you hasn't earned that standing, so it waits until you go looking. This also dissolves the shared-Process problem by construction rather than by special case: the manifest is a passive surface, so a directly-shared Process is never installed as an auto-surfacing skill. Regression fix (#2093):b7d6fc7widened the list queries but left the fetch path owner-only, so on the MCP path a record could be listed and then not opened — get_snippet raised not-found, get_process couldn't resolve, and the manifest emitted stubs whose get_process call would fail. snippets.get_snippet and notes.resolve_process now resolve the read scope. delete_snippet gained an explicit can_write_note guard, since being able to SEE a shared snippet must not imply being able to bin it. Provenance, so nothing arrives looking like the operator's own work: - access.describe_provenance / label_shared_items add shared/owner/permission; labelling costs no query when everything is the caller's own. - MCP: get_snippet, list_snippets, get_process and list_processes carry it, and get_process now says outright NOT to follow a shared process verbatim — its follow-as-written contract was the sharpest instance of the problem. - The skill stub for a shared Process names its author and asks for a go-ahead, instead of describing it as "the operator's saved Scribe process". - Policy stated once in the MCP _INSTRUCTIONS and the reusing-code skill: a shared record is that person's suggestion, weigh it, attribute it, ask before adopting it. - UI: shared snippets show "by <owner>" in the list and a notice above the code in the detail view, reusing SharedWithMeView's vocabulary. Plugin 0.1.15 -> 0.1.16 (skill text changed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
"""Knowledge service — unified query across notes, tasks, plans, and processes.
|
||||
|
||||
ACL (rules #47/#78): every query here is scoped to what the caller may READ —
|
||||
owned, directly shared, group-shared, or inherited from a shared project — via
|
||||
`access.readable_notes_clause`. These queries were owner-only until 2026-07-25,
|
||||
which meant a record shared with you could be opened by id but never *found*:
|
||||
invisible in browse, in search, and in the facet counts.
|
||||
ACL (rules #47/#78, decision note 2094): these queries were owner-only until
|
||||
2026-07-25, which meant a record shared with you could be opened by id but never
|
||||
*found*. They now honour shares — at two different widths:
|
||||
|
||||
- **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;
|
||||
- **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.
|
||||
|
||||
The asymmetry is the point. A record that appears unasked reads as one you
|
||||
endorsed, so a one-off direct share has to be searched for rather than arriving
|
||||
in your ambient lists.
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -12,7 +19,7 @@ from sqlalchemy import func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.services.access import readable_notes_clause
|
||||
from scribe.services.access import browsable_notes_clause, readable_notes_clause
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,7 +92,9 @@ async def query_knowledge(
|
||||
offset=offset, project_id=project_id,
|
||||
)
|
||||
|
||||
visible = await readable_notes_clause(user_id)
|
||||
# No query = browsing. Narrower scope: a record shared directly with the
|
||||
# caller is search-only and must not appear in an ambient list.
|
||||
visible = await browsable_notes_clause(user_id)
|
||||
async with async_session() as session:
|
||||
base = select(Note).where(visible)
|
||||
|
||||
@@ -141,6 +150,8 @@ async def _semantic_knowledge_search(
|
||||
# 1. Keyword search — title and body ILIKE
|
||||
keyword_notes: list[Note] = []
|
||||
try:
|
||||
# A typed query is an explicit act, so it reaches the caller's full read
|
||||
# scope — including records shared directly with them.
|
||||
visible = await readable_notes_clause(user_id)
|
||||
async with async_session() as session:
|
||||
pattern = f"%{q}%"
|
||||
@@ -215,11 +226,12 @@ async def _semantic_knowledge_search(
|
||||
|
||||
|
||||
async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list[str]:
|
||||
"""Return all distinct tags across the knowledge objects this user can read.
|
||||
"""Distinct tags across what this user can BROWSE.
|
||||
|
||||
Follows the list scope: a tag facet that omitted shared records would filter
|
||||
a list down to nothing for a tag that's plainly visible in it."""
|
||||
visible = await readable_notes_clause(user_id)
|
||||
Follows the browse list rather than the read scope: a facet is itself a
|
||||
passive surface, and offering a tag that only a search-only record carries
|
||||
would filter the visible list down to nothing."""
|
||||
visible = await browsable_notes_clause(user_id)
|
||||
async with async_session() as session:
|
||||
base = (
|
||||
select(func.unnest(Note.tags).label("tag"))
|
||||
@@ -232,9 +244,10 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
|
||||
|
||||
|
||||
async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]:
|
||||
"""Per-type counts for the sidebar, over what this user can read — so the
|
||||
facet numbers match the list they sit beside."""
|
||||
visible = await readable_notes_clause(user_id)
|
||||
"""Per-type counts for the sidebar, over what this user can BROWSE — so the
|
||||
numbers match the list they sit beside rather than promising rows that only a
|
||||
search would surface."""
|
||||
visible = await browsable_notes_clause(user_id)
|
||||
async with async_session() as session:
|
||||
# Count non-task types
|
||||
stmt = (
|
||||
@@ -302,7 +315,8 @@ async def query_knowledge_ids(
|
||||
)
|
||||
return [item["id"] for item in items], total
|
||||
|
||||
visible = await readable_notes_clause(user_id)
|
||||
# Browsing (see query_knowledge) — narrower scope.
|
||||
visible = await browsable_notes_clause(user_id)
|
||||
async with async_session() as session:
|
||||
base = select(Note.id).where(visible)
|
||||
|
||||
@@ -331,6 +345,8 @@ async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]:
|
||||
"""Fetch full items for the given IDs, preserving the requested order."""
|
||||
if not ids:
|
||||
return []
|
||||
# Fetching specific ids is explicit, so this takes the full read scope — the
|
||||
# ids came from either a browse or a search, and both must resolve.
|
||||
visible = await readable_notes_clause(user_id)
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
|
||||
Reference in New Issue
Block a user