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
+30 -1
View File
@@ -13,13 +13,17 @@ def _bind_user():
_user_id_ctx.reset(token)
def _fake_snippet():
def _fake_snippet(user_id: int = 7):
n = MagicMock()
n.id = 1
n.title = "debounce — rate-limit a callback"
n.body = "```js\nreturn 1\n```\n"
n.tags = ["js", "snippet"]
n.note_type = "snippet"
# Real int, matching the bound caller by default. The tools compare it to
# decide whether to attach a shared/owner marker; an auto-MagicMock would read
# as another user's record and send them off to look up a username.
n.user_id = user_id
n.to_dict.return_value = {
"id": 1, "title": n.title, "note_type": "snippet", "tags": n.tags,
}
@@ -128,6 +132,31 @@ async def test_create_and_update_pass_locations_through():
assert mock_update.await_args.kwargs["locations"] == locs
@pytest.mark.asyncio
async def test_update_snippet_read_only_share_says_why():
"""A viewer grant must be refused with the REAL reason. "Not found" would be
a lie about a record the caller can plainly open, and would send an agent
hunting for a missing id instead of recording its own version."""
with patch("scribe.services.snippets.update_snippet",
AsyncMock(side_effect=PermissionError(
"snippet 1 is shared with you read-only — ask its owner for "
"edit access, or record your own version"))):
from scribe.mcp.tools.snippets import update_snippet
with pytest.raises(ValueError, match="read-only"):
await update_snippet(1, name="x")
@pytest.mark.asyncio
async def test_merge_snippets_read_only_target_says_why():
with patch("scribe.services.snippets.merge_snippets",
AsyncMock(side_effect=PermissionError(
"snippet 1 is shared with you read-only — you can't merge "
"into a record you can't edit"))):
from scribe.mcp.tools.snippets import merge_snippets
with pytest.raises(ValueError, match="read-only"):
await merge_snippets(target_id=1, source_ids=[2])
@pytest.mark.asyncio
async def test_delete_snippet_retires_or_raises():
from scribe.mcp.tools.snippets import delete_snippet