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:
@@ -295,9 +295,17 @@ async def create_snippet(
|
||||
|
||||
|
||||
async def get_snippet(user_id: int, snippet_id: int):
|
||||
"""Fetch a snippet by id, or None if it doesn't exist / isn't a snippet."""
|
||||
note = await notes_svc.get_note(user_id, snippet_id)
|
||||
if note is None or note.note_type != SNIPPET_NOTE_TYPE:
|
||||
"""Fetch a snippet by id, or None if it doesn't exist / isn't a snippet /
|
||||
isn't readable by this user.
|
||||
|
||||
Share-aware (rule #78): a fetch by id is an explicit act, so it resolves the
|
||||
caller's full read scope rather than ownership alone. Without this, a snippet
|
||||
that a search legitimately surfaced could not then be opened — see #2093."""
|
||||
result = await notes_svc.get_note_for_user(user_id, snippet_id)
|
||||
if result is None:
|
||||
return None
|
||||
note, _permission = result
|
||||
if note.note_type != SNIPPET_NOTE_TYPE or note.deleted_at is not None:
|
||||
return None
|
||||
return note
|
||||
|
||||
@@ -405,15 +413,22 @@ async def update_snippet(
|
||||
|
||||
async def delete_snippet(user_id: int, snippet_id: int) -> bool:
|
||||
"""Retire a snippet to the trash (recoverable). Returns False if the id isn't
|
||||
the user's snippet.
|
||||
a snippet this user may WRITE.
|
||||
|
||||
Recall makes this corrective, not merely tidy: a wrong or obsolete snippet
|
||||
doesn't sit quietly — it keeps being offered as prior art. Removing it has to
|
||||
be reachable from wherever it was recorded.
|
||||
|
||||
Note the explicit write check: `get_snippet` resolves the READ scope, which
|
||||
now includes snippets merely shared with this user — being able to see one
|
||||
must not imply being able to bin it.
|
||||
"""
|
||||
note = await get_snippet(user_id, snippet_id)
|
||||
if note is None:
|
||||
return False
|
||||
from scribe.services.access import can_write_note
|
||||
if not await can_write_note(user_id, snippet_id):
|
||||
return False
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
return await trash_delete(note.user_id, "note", snippet_id) is not None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user