fix(acl): align MCP writes with the share model — editor edits, viewer doesn't
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 34s
CI & Build / Build & push image (push) Has been skipped

Option B, per the operator. Closes the last inconsistency from the ACL work.

The agent path had drifted into an indefensible position: delete_snippet honoured
editor shares (I made it share-aware so widening the read wouldn't let a VIEWER
trash things) while update_snippet still resolved through the owner-only
notes.get_note. So through an agent you could destroy a colleague's snippet but
not improve it — and the refusal claimed "not found" for a record you could
plainly open.

Now update_snippet, merge_snippets and update_process all resolve the read scope
and then require can_write_note, matching the REST routes and the sharing UI's
own promise that viewer / editor / admin are distinct grants. A viewer grant is
refused with the actual reason ("shared with you read-only — ask its owner for
edit access, or record your own version"), because not-found would send an agent
hunting for a missing id instead of recording its own copy.

Authorised writes are performed as the OWNER, since the underlying note update is
owner-scoped and a shared editor's own id would match nothing.

Merge additionally requires each source to share the TARGET'S owner and to be
writable by the caller — merging trashes the source, so read access isn't enough,
and cross-owner merge stays out of scope (#231). Sources failing either test are
skipped rather than half-merged.

A record the caller cannot read at all still returns not-found rather than
forbidden, so the error can't be used to confirm that an id exists.

Also fixed _fake_snippet's missing user_id proactively — the same
auto-MagicMock-reads-as-foreign trap that broke CI twice (see note 2109).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
2026-07-26 00:04:48 -04:00
parent e9cd3435ad
commit 3ffdbbc521
6 changed files with 236 additions and 32 deletions
+20 -5
View File
@@ -88,11 +88,21 @@ async def get_process(name_or_id: str) -> dict:
async def update_process(process_id: int, title: str = "", body: str = "",
tags: list[str] | None = None) -> dict:
"""Update a stored process. Only provided fields change — empty title/body
leave that field unchanged; pass tags to replace the tag set."""
leave that field unchanged; pass tags to replace the tag set.
Editing another user's process requires an editor or admin share from them; a
read-only share is not enough and says so rather than claiming not-found.
"""
uid = current_user_id()
note = await notes_svc.get_note(uid, process_id)
if note is None or note.note_type != "process":
loaded = await notes_svc.get_note_for_user(uid, process_id)
note = loaded[0] if loaded else None
if note is None or note.note_type != "process" or note.deleted_at is not None:
raise ValueError(f"process {process_id} not found")
if not await access_svc.can_write_note(uid, process_id):
raise ValueError(
f"process {process_id} is shared with you read-only — ask its owner "
f"for edit access, or save your own copy with create_process"
)
fields: dict = {}
if title.strip():
fields["title"] = title.strip()
@@ -100,8 +110,13 @@ async def update_process(process_id: int, title: str = "", body: str = "",
fields["body"] = body
if tags is not None:
fields["tags"] = tags
updated = await notes_svc.update_note(uid, process_id, **fields)
return updated.to_dict()
# As the owner — update_note is owner-scoped and the write is authorised above.
updated = await notes_svc.update_note(note.user_id, process_id, **fields)
if updated is None:
raise ValueError(f"process {process_id} not found")
out = updated.to_dict()
out.update(await access_svc.describe_provenance(uid, updated))
return out
def register(mcp) -> None: