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:
@@ -16,6 +16,7 @@ from scribe.models.group import GroupMembership
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.share import NoteShare, ProjectShare
|
||||
from scribe.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -165,6 +166,20 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set-based visibility (for LIST queries)
|
||||
#
|
||||
# Two scopes, deliberately different (decision note 2094):
|
||||
#
|
||||
# readable_* — everything the ACL permits, including a record reachable ONLY
|
||||
# through a direct or group note share. For EXPLICIT acts: a
|
||||
# search the caller typed, or a fetch by id.
|
||||
# browsable_* — the caller's own records plus anything in a project they have
|
||||
# access to. For PASSIVE surfaces: browse lists, facet counts,
|
||||
# the process→skill manifest.
|
||||
#
|
||||
# The split is a trust boundary, not an optimisation. Anything that appears
|
||||
# unasked — in your own list, your own counts, or as a skill installed on your
|
||||
# machine — reads as material you endorsed. A one-off record someone shared with
|
||||
# you has not earned that standing, so it waits until you go looking for it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def readable_notes_clause(user_id: int):
|
||||
@@ -201,3 +216,85 @@ async def readable_notes_clause(user_id: int):
|
||||
Note.id.in_(shared_note_ids),
|
||||
Note.project_id.in_(shared_project_ids),
|
||||
)
|
||||
|
||||
|
||||
async def describe_provenance(user_id: int, note) -> dict:
|
||||
"""Provenance for a record being handed to a caller: `{}` when it's theirs,
|
||||
otherwise `{"shared": True, "owner": <username>, "permission": <perm>}`.
|
||||
|
||||
Anything reaching an agent or a UI from another person has to say so. A
|
||||
shared record is one person's suggestion, not a standard the caller adopted,
|
||||
and without a marker the two are indistinguishable — the reader would assume
|
||||
their own past self wrote it and treat it as settled practice.
|
||||
"""
|
||||
if note is None or note.user_id == user_id:
|
||||
return {}
|
||||
async with async_session() as session:
|
||||
owner = await session.get(User, note.user_id)
|
||||
return {
|
||||
"shared": True,
|
||||
"owner": owner.username if owner else None,
|
||||
"permission": await get_note_permission(user_id, note.id),
|
||||
}
|
||||
|
||||
|
||||
async def label_shared_items(user_id: int, items: list[dict]) -> list[dict]:
|
||||
"""Mark the entries in a list payload that belong to someone else.
|
||||
|
||||
Each foreign item gains `shared: True` and `owner: <username>`; the caller's
|
||||
own items are left untouched so an all-mine list stays noise-free. Usernames
|
||||
are resolved in one query per distinct owner, not one per row.
|
||||
|
||||
Lists are where provenance matters most: an unmarked row in your own list
|
||||
reads as something you recorded and vetted.
|
||||
"""
|
||||
foreign = {
|
||||
it["user_id"] for it in items
|
||||
if it.get("user_id") is not None and it["user_id"] != user_id
|
||||
}
|
||||
if not foreign:
|
||||
return items
|
||||
async with async_session() as session:
|
||||
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:
|
||||
owner_id = it.get("user_id")
|
||||
if owner_id is not None and owner_id != user_id:
|
||||
it["shared"] = True
|
||||
it["owner"] = names.get(owner_id)
|
||||
return items
|
||||
|
||||
|
||||
async def browsable_notes_clause(user_id: int):
|
||||
"""A WHERE predicate for PASSIVE surfaces: the caller's own notes, plus
|
||||
notes in a project they can reach (owned or shared).
|
||||
|
||||
Deliberately narrower than `readable_notes_clause` — it omits records the
|
||||
caller can read *only* via a direct or group note share. Those stay
|
||||
search-only, so a one-off someone handed them never drifts into a browse
|
||||
list, a facet count, or the skill manifest as though it were their own.
|
||||
|
||||
Project membership is the seam because it's a standing, mutual context: if
|
||||
you're on the project, its content is your working material. A note with no
|
||||
project that you don't own therefore never matches — `NULL IN (...)` is not
|
||||
true — which is exactly the intent.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
group_ids = await _user_group_ids(session, user_id)
|
||||
|
||||
project_share_conds = [ProjectShare.shared_with_user_id == user_id]
|
||||
if group_ids:
|
||||
project_share_conds.append(ProjectShare.shared_with_group_id.in_(group_ids))
|
||||
|
||||
shared_project_ids = select(ProjectShare.project_id).where(or_(*project_share_conds))
|
||||
owned_project_ids = select(Project.id).where(Project.user_id == user_id)
|
||||
|
||||
return or_(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id.in_(shared_project_ids),
|
||||
Note.project_id.in_(owned_project_ids),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user