From e97bdc19d932eefb2c3f6ff3c46c95c1f8bb8d29 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 14 Jul 2026 13:02:53 +0100 Subject: [PATCH 1/2] feat(canvas): agent scope + permission gating and honest attribution Gate the canvas routes by agent scope and per-project canvas permission (slice 3 of the lead-agent identity epic), fix element attribution so a session user is recorded by id and an agent principal by its agent id, and stamp a uniform {kind, id} actor on every canvas event. Docs-Reviewed: canvas route gating + attribution + event actor per lead-agent-identity-and-canvas-access.md --- tests/projects/test_canvas_integration.py | 11 + tests/projects/test_canvas_mcp_tools.py | 57 ++-- tests/projects/test_canvas_store.py | 15 +- tests/projects/test_routes_canvas.py | 11 + tests/test_routes_project_canvas.py | 360 +++++++++++++++++++++- tinyagentos/projects/canvas/mcp_tools.py | 65 ++-- tinyagentos/projects/canvas/store.py | 16 +- tinyagentos/projects/project_store.py | 12 + tinyagentos/routes/project_canvas.py | 148 ++++++++- 9 files changed, 620 insertions(+), 75 deletions(-) diff --git a/tests/projects/test_canvas_integration.py b/tests/projects/test_canvas_integration.py index c7930bfb2..39b732ce7 100644 --- a/tests/projects/test_canvas_integration.py +++ b/tests/projects/test_canvas_integration.py @@ -30,6 +30,17 @@ async def app_env(tmp_path): await snap._ensure_subscribed(p["id"]) app = FastAPI() + + # The gated canvas routes authorize via request.state.user_id / is_admin + # (set by the auth middleware in production). This bare test app has no + # middleware, so inject a fixed admin session to mirror the unchanged + # session-owner behavior the routes rely on. + @app.middleware("http") + async def _inject_session(request, call_next): + request.state.user_id = "admin" + request.state.is_admin = True + return await call_next(request) + app.state.project_store = ps app.state.project_canvas_store = cs app.state.canvas_snapshotter = snap diff --git a/tests/projects/test_canvas_mcp_tools.py b/tests/projects/test_canvas_mcp_tools.py index 4bc191c2d..b572880b0 100644 --- a/tests/projects/test_canvas_mcp_tools.py +++ b/tests/projects/test_canvas_mcp_tools.py @@ -28,7 +28,16 @@ async def env(tmp_path): ctx = ct.CanvasToolContext( project_store=ps, canvas_store=cs, snapshotter=snap, data_root=data_root, ) - yield p, ctx, ps + + async def _grant_edit(): + await ps._db.execute( + "UPDATE project_members SET can_edit_canvas = 1 " + "WHERE project_id = ? AND member_id = ?", + (p["id"], "agent-1"), + ) + await ps._db.commit() + + yield p, ctx, ps, _grant_edit await snap.stop() await cs.close() await ps.close() @@ -36,7 +45,8 @@ async def env(tmp_path): @pytest.mark.asyncio async def test_canvas_add_note_creates_element(env): - p, ctx, _ = env + p, ctx, _ps, grant = env + await grant() res = await ct.canvas_add_note( ctx, project_id=p["id"], agent_id="agent-1", text="agent-said-hello", x=10, y=20, @@ -48,7 +58,8 @@ async def test_canvas_add_note_creates_element(env): @pytest.mark.asyncio async def test_canvas_add_text_creates_element(env): - p, ctx, _ = env + p, ctx, _ps, grant = env + await grant() res = await ct.canvas_add_text( ctx, project_id=p["id"], agent_id="agent-1", text="an idea", x=5, y=5, font_size=20, @@ -62,7 +73,8 @@ async def test_canvas_add_text_creates_element(env): @pytest.mark.asyncio async def test_canvas_add_mermaid_and_flowchart_carry_source(env): - p, ctx, _ = env + p, ctx, _ps, grant = env + await grant() m = await ct.canvas_add_mermaid( ctx, project_id=p["id"], agent_id="agent-1", source="graph TD; A-->B", x=0, y=0, @@ -79,7 +91,8 @@ async def test_canvas_add_mermaid_and_flowchart_carry_source(env): @pytest.mark.asyncio async def test_canvas_add_mindmap_edge_links_endpoints(env): - p, ctx, _ = env + p, ctx, _ps, grant = env + await grant() a = await ct.canvas_add_text( ctx, project_id=p["id"], agent_id="agent-1", text="a", x=0, y=0, ) @@ -100,7 +113,8 @@ async def test_canvas_add_mindmap_edge_links_endpoints(env): @pytest.mark.asyncio async def test_canvas_add_mindmap_edge_rejects_unknown_endpoint(env): - p, ctx, _ = env + p, ctx, _ps, grant = env + await grant() a = await ct.canvas_add_text( ctx, project_id=p["id"], agent_id="agent-1", text="a", x=0, y=0, ) @@ -112,32 +126,38 @@ async def test_canvas_add_mindmap_edge_rejects_unknown_endpoint(env): @pytest.mark.asyncio -async def test_canvas_update_denied_without_permission(env): - p, ctx, _ = env - note = await ct.canvas_add_note( +async def test_canvas_add_denied_without_permission(env): + p, ctx, _ps, _grant = env + res = await ct.canvas_add_note( ctx, project_id=p["id"], agent_id="agent-1", text="x", x=0, y=0, ) + assert res["error"] == "permission_denied" + + +@pytest.mark.asyncio +async def test_canvas_update_denied_without_permission(env): + p, ctx, _ps, _grant = env + # An agent without the edit flag cannot mutate an existing element. + el = await ctx.canvas_store.add_element( + project_id=p["id"], author_kind="user", author_id="u", + element={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, "payload": {"text": "x"}}, + ) res = await ct.canvas_update_element( ctx, project_id=p["id"], agent_id="agent-1", - element_id=note["element"]["id"], patch={"x": 99}, + element_id=el["id"], patch={"x": 99}, ) assert res["error"] == "permission_denied" @pytest.mark.asyncio async def test_canvas_update_succeeds_with_permission(env): - p, ctx, ps = env + p, ctx, ps, grant = env + await grant() note = await ct.canvas_add_note( ctx, project_id=p["id"], agent_id="agent-1", text="x", x=0, y=0, ) - await ps._db.execute( - "UPDATE project_members SET can_edit_canvas = 1 " - "WHERE project_id = ? AND member_id = ?", - (p["id"], "agent-1"), - ) - await ps._db.commit() res = await ct.canvas_update_element( ctx, project_id=p["id"], agent_id="agent-1", element_id=note["element"]["id"], patch={"x": 99}, @@ -147,7 +167,8 @@ async def test_canvas_update_succeeds_with_permission(env): @pytest.mark.asyncio async def test_canvas_get_snapshot_png_writes_file(env): - p, ctx, _ = env + p, ctx, _ps, grant = env + await grant() await ct.canvas_add_note( ctx, project_id=p["id"], agent_id="agent-1", text="x", x=0, y=0, ) diff --git a/tests/projects/test_canvas_store.py b/tests/projects/test_canvas_store.py index 464be1a3f..bd772e946 100644 --- a/tests/projects/test_canvas_store.py +++ b/tests/projects/test_canvas_store.py @@ -73,10 +73,17 @@ async def test_user_can_add_ideas_board_kind(store, kind): @pytest.mark.parametrize("kind", ["text", "mermaid", "flowchart", "mindmap_edge"]) @pytest.mark.asyncio -async def test_agent_can_emit_ideas_board_kind(store, kind): - # Agents may paint the ideas-board kinds via the canvas tools (#68). - e = await store.add_element( - project_id="p", +async def test_agent_can_emit_ideas_board_kind(store_with_member, kind): + # Agents may paint the ideas-board kinds via the canvas tools (#68). The + # store enforces can_edit_canvas, so the member needs the edit flag set. + cs, _ = store_with_member + await cs._db.execute( + "UPDATE project_members SET can_edit_canvas = 1 " + "WHERE project_id = 'p1' AND member_id = 'agent-1'" + ) + await cs._db.commit() + e = await cs.add_element( + project_id="p1", element={"kind": kind, "x": 0, "y": 0, "w": 1, "h": 1, "payload": {}}, author_kind="agent", author_id="agent-1", ) diff --git a/tests/projects/test_routes_canvas.py b/tests/projects/test_routes_canvas.py index eecf98a49..592b3fa05 100644 --- a/tests/projects/test_routes_canvas.py +++ b/tests/projects/test_routes_canvas.py @@ -31,6 +31,17 @@ async def client(tmp_path): await snap.start() app = FastAPI() + + # The gated canvas routes authorize via request.state.user_id / is_admin + # (set by the auth middleware in production). This bare test app has no + # middleware, so inject a fixed admin session for every request to mirror + # the unchanged session-owner behavior the routes rely on. + @app.middleware("http") + async def _inject_session(request, call_next): + request.state.user_id = "admin" + request.state.is_admin = True + return await call_next(request) + app.state.project_store = ps app.state.project_canvas_store = cs app.state.canvas_snapshotter = snap diff --git a/tests/test_routes_project_canvas.py b/tests/test_routes_project_canvas.py index 220912054..d4968ea70 100644 --- a/tests/test_routes_project_canvas.py +++ b/tests/test_routes_project_canvas.py @@ -3,27 +3,32 @@ from __future__ import annotations import asyncio -import json -from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch, AsyncMock import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from tinyagentos.agent_registry_store import mint_registry_token @pytest.fixture(autouse=True) def _ensure_canvas_store(client, tmp_path_factory): - """Initialize project_canvas_store if the lifespan didn't run (test client).""" + """Initialize project_canvas_store if the lifespan didn't run (test client). + + Point the canvas store at the same projects.db the project_store uses, so + the per-project canvas permission live in the shared project_members table + (the store-level edit check reads from there, exactly as in production). + """ store = client._transport.app.state.project_canvas_store if store._db is not None: try: asyncio.get_event_loop().run_until_complete(store.close()) except Exception: pass - # Use a fresh DB per test session via tmp_path. BaseStore reads self.db_path - # (a Path) in init(); the previous override set a non-existent `_db_path` - # string attr, so init() silently fell back to the production canvas DB. - tmp_dir = tmp_path_factory.mktemp("canvas_test") - store.db_path = tmp_dir / "test_projects.db" + ps = client._transport.app.state.project_store + store.db_path = ps.db_path asyncio.get_event_loop().run_until_complete(store.init()) yield try: @@ -368,8 +373,11 @@ async def test_snapshot_tldr_requires_snapshotter(client): @pytest.mark.asyncio async def test_set_permission_member_not_found_returns_404(client): + resp = await client.post("/api/projects", json={"name": "perm", "slug": "perm"}) + assert resp.status_code == 200, resp.text + pid = resp.json()["id"] resp = await client.patch( - "/api/projects/proj-1/canvas/permissions/agent-1", + f"/api/projects/{pid}/canvas/permissions/agent-1", json={"can_edit_canvas": True}, ) assert resp.status_code == 404 @@ -389,3 +397,337 @@ async def test_canvas_stream_endpoint_exists(client): app = client._transport.app paths = [r.path for r in app.routes] assert "/api/projects/{project_id}/canvas/stream" in paths + + +# --------------------------------------------------------------------------- +# Slice 3: agent scope + per-project canvas permission gating (D3 matrix) +# plus honest attribution and the uniform actor stamp (D4). +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def ctx(client): + """Reuse the session-admin `client` app and init the agent registry + + grants stores so canvas agent tokens can be minted against real stores.""" + app = client._transport.app + for attr in ("agent_registry", "agent_grants"): + store = getattr(app.state, attr) + if store._db is None: + await store.init() + # The test client bypasses the lifespan, so app.state.project_event_broker + # is unset; point it at the broker the canvas store already publishes to. + if getattr(app.state, "project_event_broker", None) is None: + app.state.project_event_broker = app.state.project_canvas_store._broker + uid = app.state.auth.find_user("admin")["id"] + yield SimpleNamespace(client=client, app=app, uid=uid) + for attr in ("agent_registry", "agent_grants"): + store = getattr(app.state, attr) + if store._db is not None: + await store.close() + + +def _bare(app): + """Cookieless client so requests carry only the Bearer header.""" + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + +def _hdr(token): + return {"Authorization": f"Bearer {token}"} + + +async def _new_project(ctx, slug): + resp = await ctx.client.post("/api/projects", json={"name": slug, "slug": slug}) + assert resp.status_code == 200, resp.text + return resp.json()["id"] + + +async def _add_member(ctx, pid, member_id): + await ctx.app.state.project_store.add_member(pid, member_id, member_kind="native") + + +async def _mint_agent(ctx, project_id, scopes): + registry = ctx.app.state.agent_registry + grants = ctx.app.state.agent_grants + priv, _pub = ctx.app.state.agent_registry_keypair + rec = await registry.register( + framework="grok", + display_name="Grok", + origin="external-selfjoin", + handle="@grok", + ) + cid = rec["canonical_id"] + await registry.set_status(cid, "active") + for scope in scopes: + await grants.add_grant(cid, scope, project_id=project_id) + token = mint_registry_token( + cid, priv, user_id="u", framework="grok", project_id=project_id + ) + return cid, token + + +async def _grant_canvas(ctx, pid, agent_id, *, read=None, edit=None): + body = {} + if read is not None: + body["can_read_canvas"] = read + if edit is not None: + body["can_edit_canvas"] = edit + resp = await ctx.client.patch( + f"/api/projects/{pid}/canvas/permissions/{agent_id}", json=body + ) + assert resp.status_code == 200, resp.text + return resp + + +@pytest.mark.asyncio +class TestAgentCanvasReadGating: + async def test_read_allowed_with_scope_and_flag(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid, ("canvas_read",)) + await _add_member(ctx, pid, cid) + await _grant_canvas(ctx, pid, cid, read=True) + async with _bare(ctx.app) as bare: + resp = await bare.get( + f"/api/projects/{pid}/canvas/elements", headers=_hdr(token) + ) + assert resp.status_code == 200 + + async def test_read_without_scope_is_403(self, ctx): + pid = await _new_project(ctx, "alpha") + # canvas_write only: read scope is missing. + cid, token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + await _grant_canvas(ctx, pid, cid, read=True) + async with _bare(ctx.app) as bare: + resp = await bare.get( + f"/api/projects/{pid}/canvas/elements", headers=_hdr(token) + ) + assert resp.status_code == 403 + + async def test_read_without_flag_is_403(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid, ("canvas_read",)) + await _add_member(ctx, pid, cid) + # No can_read_canvas grant. + async with _bare(ctx.app) as bare: + resp = await bare.get( + f"/api/projects/{pid}/canvas/elements", headers=_hdr(token) + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +class TestAgentCanvasWriteGating: + async def test_write_allowed_with_scope_and_flag(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + await _grant_canvas(ctx, pid, cid, edit=True) + async with _bare(ctx.app) as bare: + resp = await bare.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "a"}}, + headers=_hdr(token), + ) + assert resp.status_code == 201, resp.text + + async def test_write_without_scope_is_403(self, ctx): + pid = await _new_project(ctx, "alpha") + # canvas_read only: write scope is missing. + cid, token = await _mint_agent(ctx, pid, ("canvas_read",)) + await _add_member(ctx, pid, cid) + await _grant_canvas(ctx, pid, cid, edit=True) + async with _bare(ctx.app) as bare: + resp = await bare.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "a"}}, + headers=_hdr(token), + ) + assert resp.status_code == 403 + + async def test_write_without_flag_is_403(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + # No can_edit_canvas grant. + async with _bare(ctx.app) as bare: + resp = await bare.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "a"}}, + headers=_hdr(token), + ) + assert resp.status_code == 403 + + async def test_write_flag_does_not_grant_read(self, ctx): + """The edit flag (and canvas_write scope) must NOT satisfy a read.""" + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + await _grant_canvas(ctx, pid, cid, edit=True) + async with _bare(ctx.app) as bare: + resp = await bare.get( + f"/api/projects/{pid}/canvas/elements", headers=_hdr(token) + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +class TestAgentCanvasCrossProject: + async def test_different_project_is_404(self, ctx): + pid_a = await _new_project(ctx, "alpha") + pid_b = await _new_project(ctx, "bravo") + cid, token_a = await _mint_agent(ctx, pid_a, ("canvas_read",)) + await _add_member(ctx, pid_a, cid) + await _grant_canvas(ctx, pid_a, cid, read=True) + async with _bare(ctx.app) as bare: + listing = await bare.get( + f"/api/projects/{pid_b}/canvas/elements", headers=_hdr(token_a) + ) + assert listing.status_code == 404 + + +@pytest.mark.asyncio +class TestCanvasAttribution: + async def test_agent_write_carry_agent_fields(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + await _grant_canvas(ctx, pid, cid, edit=True) + async with _bare(ctx.app) as bare: + resp = await bare.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "a"}}, + headers=_hdr(token), + ) + assert resp.status_code == 201, resp.text + el = resp.json()["element"] + assert el["author_kind"] == "agent" + assert el["author_id"] == cid + + async def test_user_write_attributed_to_user_not_system(self, ctx): + pid = await _new_project(ctx, "alpha") + resp = await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "a"}}, + ) + assert resp.status_code == 201, resp.text + el = resp.json()["element"] + assert el["author_kind"] == "user" + assert el["author_id"] == ctx.uid + assert el["author_id"] != "system" + + +@pytest.mark.asyncio +class TestCanvasEventActor: + """D4: a uniform actor object {kind, id} is stamped on every canvas.* event.""" + + async def _capture(self, ctx, pid, coro): + broker = ctx.app.state.project_event_broker + queue = await broker.subscribe(pid) + # Drain any replayed events from earlier actions in this test so we + # observe only the event produced by the action under test. + while True: + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break + await coro() + return await asyncio.wait_for(queue.get(), timeout=5.0) + + async def test_actor_on_create_event(self, ctx): + pid = await _new_project(ctx, "alpha") + + async def action(): + await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "a"}}, + ) + + ev = await self._capture(ctx, pid, action) + assert ev.kind == "canvas.element_added" + assert ev.payload["actor"] == {"kind": "user", "id": ctx.uid} + + async def test_actor_on_delete_event(self, ctx): + pid = await _new_project(ctx, "alpha") + created = await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "a"}}, + ) + eid = created.json()["element"]["id"] + + async def action(): + await ctx.client.delete( + f"/api/projects/{pid}/canvas/elements/{eid}" + ) + + ev = await self._capture(ctx, pid, action) + assert ev.kind == "canvas.element_deleted" + assert ev.payload["actor"] == {"kind": "user", "id": ctx.uid} + + async def test_actor_on_permission_change_event(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, _token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + + async def action(): + await ctx.client.patch( + f"/api/projects/{pid}/canvas/permissions/{cid}", + json={"can_edit_canvas": True}, + ) + + ev = await self._capture(ctx, pid, action) + assert ev.kind == "canvas.permission_changed" + assert ev.payload["actor"] == {"kind": "user", "id": ctx.uid} + assert ev.payload["can_edit_canvas"] is True + + +@pytest.mark.asyncio +class TestPermissionPatchExtendsFlags: + async def test_patch_can_set_read_flag_only(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, _token = await _mint_agent(ctx, pid, ("canvas_read",)) + await _add_member(ctx, pid, cid) + resp = await ctx.client.patch( + f"/api/projects/{pid}/canvas/permissions/{cid}", + json={"can_read_canvas": True}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["can_read_canvas"] is True + assert resp.json()["can_edit_canvas"] is False + members = await ctx.app.state.project_store.list_members(pid) + me = next(m for m in members if m["member_id"] == cid) + assert me["can_read_canvas"] == 1 + assert me["can_edit_canvas"] == 0 + + async def test_patch_can_set_edit_flag_only(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, _token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + resp = await ctx.client.patch( + f"/api/projects/{pid}/canvas/permissions/{cid}", + json={"can_edit_canvas": True}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["can_edit_canvas"] is True + assert resp.json()["can_read_canvas"] is False + + async def test_patch_rejects_agent_token(self, ctx): + """The permissions PATCH is owner/admin only; an agent token must not + reach it (the middleware leaves it off the agent allowlist -> 401).""" + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + async with _bare(ctx.app) as bare: + resp = await bare.patch( + f"/api/projects/{pid}/canvas/permissions/{cid}", + json={"can_edit_canvas": True}, + headers=_hdr(token), + ) + assert resp.status_code == 401 + diff --git a/tinyagentos/projects/canvas/mcp_tools.py b/tinyagentos/projects/canvas/mcp_tools.py index ce4cee46c..ca1b755cb 100644 --- a/tinyagentos/projects/canvas/mcp_tools.py +++ b/tinyagentos/projects/canvas/mcp_tools.py @@ -35,20 +35,41 @@ async def canvas_list_elements(ctx: CanvasToolContext, *, project_id: str) -> di return {"elements": elements} +async def _add_agent_element( + ctx: CanvasToolContext, *, project_id: str, agent_id: str, element: dict +) -> dict: + """Add an element authored by an agent, surfacing the edit-permission gate + as a clean error (mirrors canvas_update_element / canvas_delete_element).""" + try: + el = await ctx.canvas_store.add_element( + project_id=project_id, + author_kind="agent", author_id=agent_id, + element=element, + ) + except CanvasPermissionError: + return { + "error": "permission_denied", + "message": ( + "This agent does not have edit permission on the canvas. " + "Ask the user to enable it in project settings, or message " + "them to make the change." + ), + } + return {"element": el} + + async def canvas_add_note( ctx: CanvasToolContext, *, project_id: str, agent_id: str, text: str, x: float, y: float, color: str = "yellow", ) -> dict: - el = await ctx.canvas_store.add_element( - project_id=project_id, - author_kind="agent", author_id=agent_id, + return await _add_agent_element( + ctx, project_id=project_id, agent_id=agent_id, element={ "kind": "note", "x": float(x), "y": float(y), "w": 200.0, "h": 100.0, "payload": {"text": text, "color": color, "font_size": 14}, }, ) - return {"element": el} async def canvas_add_link( @@ -56,77 +77,67 @@ async def canvas_add_link( url: str, x: float, y: float, ) -> dict: meta = await fetch_link_metadata(url) - el = await ctx.canvas_store.add_element( - project_id=project_id, - author_kind="agent", author_id=agent_id, + return await _add_agent_element( + ctx, project_id=project_id, agent_id=agent_id, element={ "kind": "link", "x": float(x), "y": float(y), "w": 320.0, "h": 120.0, "payload": meta, }, ) - return {"element": el} async def canvas_add_image( ctx: CanvasToolContext, *, project_id: str, agent_id: str, file_id: str, x: float, y: float, alt: str = "", ) -> dict: - el = await ctx.canvas_store.add_element( - project_id=project_id, - author_kind="agent", author_id=agent_id, + return await _add_agent_element( + ctx, project_id=project_id, agent_id=agent_id, element={ "kind": "image", "x": float(x), "y": float(y), "w": 240.0, "h": 240.0, "payload": {"file_id": file_id, "alt": alt, "mime": "image/png"}, }, ) - return {"element": el} async def canvas_add_text( ctx: CanvasToolContext, *, project_id: str, agent_id: str, text: str, x: float, y: float, font_size: int = 16, ) -> dict: - el = await ctx.canvas_store.add_element( - project_id=project_id, - author_kind="agent", author_id=agent_id, + return await _add_agent_element( + ctx, project_id=project_id, agent_id=agent_id, element={ "kind": "text", "x": float(x), "y": float(y), "w": 220.0, "h": 80.0, "payload": {"text": text, "font_size": int(font_size)}, }, ) - return {"element": el} async def canvas_add_mermaid( ctx: CanvasToolContext, *, project_id: str, agent_id: str, source: str, x: float, y: float, ) -> dict: - el = await ctx.canvas_store.add_element( - project_id=project_id, - author_kind="agent", author_id=agent_id, + return await _add_agent_element( + ctx, project_id=project_id, agent_id=agent_id, element={ "kind": "mermaid", "x": float(x), "y": float(y), "w": 320.0, "h": 240.0, "payload": {"source": source}, }, ) - return {"element": el} async def canvas_add_flowchart( ctx: CanvasToolContext, *, project_id: str, agent_id: str, source: str, x: float, y: float, ) -> dict: - el = await ctx.canvas_store.add_element( - project_id=project_id, - author_kind="agent", author_id=agent_id, + return await _add_agent_element( + ctx, project_id=project_id, agent_id=agent_id, element={ "kind": "flowchart", "x": float(x), "y": float(y), "w": 320.0, "h": 240.0, "payload": {"source": source}, }, ) - return {"element": el} async def canvas_add_mindmap_edge( @@ -147,9 +158,8 @@ async def canvas_add_mindmap_edge( } ax, ay = a["x"] + a["w"] / 2, a["y"] + a["h"] / 2 bx, by = b["x"] + b["w"] / 2, b["y"] + b["h"] / 2 - el = await ctx.canvas_store.add_element( - project_id=project_id, - author_kind="agent", author_id=agent_id, + return await _add_agent_element( + ctx, project_id=project_id, agent_id=agent_id, element={ "kind": "mindmap_edge", "x": min(ax, bx), "y": min(ay, by), @@ -157,7 +167,6 @@ async def canvas_add_mindmap_edge( "payload": {"from": from_id, "to": to_id}, }, ) - return {"element": el} async def canvas_update_element( diff --git a/tinyagentos/projects/canvas/store.py b/tinyagentos/projects/canvas/store.py index 55995e66a..fee2e2659 100644 --- a/tinyagentos/projects/canvas/store.py +++ b/tinyagentos/projects/canvas/store.py @@ -124,6 +124,10 @@ async def add_element( raise ValueError(f"agents may not emit kind={kind}") if author_kind not in ("user", "agent"): raise ValueError(f"invalid author_kind: {author_kind}") + # Enforce the per-project edit permission for agents (defense in depth: + # the route already gates on scope + can_edit_canvas, but the store is + # the authoritative floor so a direct caller can never bypass it). + await self._check_edit_permission(project_id, author_kind, author_id) eid = element.get("id") or new_id("cve") now = time.time() # Upsert: the client may re-send an element it already created (e.g. a @@ -154,7 +158,10 @@ async def add_element( ) await self._db.commit() new_el = await self.get_element(eid) - await self._publish(project_id, "canvas.element_added", {"element": new_el}) + await self._publish( + project_id, "canvas.element_added", + {"element": new_el, "actor": {"kind": author_kind, "id": author_id}}, + ) return new_el async def list_elements( @@ -239,7 +246,10 @@ async def update_element( updated = await self.get_element(element_id, project_id=project_id) if updated is None: raise ValueError(f"element not found: {element_id}") - await self._publish(project_id, "canvas.element_updated", {"element": updated}) + await self._publish( + project_id, "canvas.element_updated", + {"element": updated, "actor": {"kind": author_kind, "id": author_id}}, + ) return updated async def delete_element( @@ -262,5 +272,5 @@ async def delete_element( if cur.rowcount == 1: await self._publish( project_id, "canvas.element_deleted", - {"element_id": element_id}, + {"element_id": element_id, "actor": {"kind": author_kind, "id": author_id}}, ) diff --git a/tinyagentos/projects/project_store.py b/tinyagentos/projects/project_store.py index 875b75fd2..7f19aba14 100644 --- a/tinyagentos/projects/project_store.py +++ b/tinyagentos/projects/project_store.py @@ -67,6 +67,7 @@ class ProjectStore(BaseStore): async def _post_init(self) -> None: for col_def in ( "ALTER TABLE project_members ADD COLUMN can_edit_canvas INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE project_members ADD COLUMN can_read_canvas INTEGER NOT NULL DEFAULT 0", "ALTER TABLE project_members ADD COLUMN is_lead INTEGER NOT NULL DEFAULT 0", "ALTER TABLE projects ADD COLUMN user_id TEXT NOT NULL DEFAULT ''", ): @@ -244,6 +245,17 @@ async def list_members(self, project_id: str) -> list[dict]: keys = [d[0] for d in cur.description] return [dict(zip(keys, r)) for r in rows] + async def get_member(self, project_id: str, member_id: str) -> dict | None: + async with self._db.execute( + "SELECT * FROM project_members WHERE project_id = ? AND member_id = ?", + (project_id, member_id), + ) as cur: + row = await cur.fetchone() + if row is None: + return None + keys = [d[0] for d in cur.description] + return dict(zip(keys, row)) + async def log_activity( self, project_id: str, diff --git a/tinyagentos/routes/project_canvas.py b/tinyagentos/routes/project_canvas.py index 99c72de91..59fb20fef 100644 --- a/tinyagentos/routes/project_canvas.py +++ b/tinyagentos/routes/project_canvas.py @@ -9,7 +9,7 @@ import logging from typing import Literal -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, Response from pydantic import BaseModel, Field @@ -20,14 +20,77 @@ logger = logging.getLogger(__name__) router = APIRouter() +# Agent scopes (slice 1) that unlock canvas read / write on a project the token +# is bound to. The write scope is strictly narrower than read: holding +# canvas_write must NEVER satisfy a read, and vice versa (D3 enforcement matrix). +_CANVAS_READ_SCOPE = "canvas_read" +_CANVAS_WRITE_SCOPE = "canvas_write" + def _user_id(request: Request) -> str: - user = getattr(request.state, "user", None) - if user and isinstance(user, dict) and "id" in user: - return user["id"] + uid = getattr(request.state, "user_id", None) + if uid: + return uid return "system" +async def _authorize_canvas_actor( + request: Request, project_id: str, mode: Literal["read", "write"] +) -> "tuple[str, str] | JSONResponse": + """Resolve + authorize the actor for a canvas route. + + Accepts EITHER a session owner/admin (behavior unchanged from before the + agent gate) OR an approved external agent's registry JWT bound to THIS + project with the matching canvas scope AND the matching per-project member + flag: + + * read mode -> canvas_read scope + can_read_canvas member flag + * write mode -> canvas_write scope + can_edit_canvas member flag + + Returns ``(actor_kind, actor_id)`` on success, or a JSONResponse to return + directly. A token bound to a DIFFERENT project collapses into an + existence-hiding 404 (never confirms the project exists). A token for this + project that is missing the scope or the member flag gets 403. + """ + uid = getattr(request.state, "user_id", None) + if uid: + # Session owner/admin: unchanged session behavior, attributed to the user. + return ("user", _user_id(request)) + auth_header = request.headers.get("Authorization", "") + if not auth_header.lower().startswith("bearer "): + # Middleware normally 401s unauthenticated requests before the route + # runs; a middleware-bypassing test context reaches here, so fall back + # to a system actor (there is no real principal to attribute to). + return ("user", "system") + from tinyagentos.agent_token_auth import ( + check_agent_scope_for_project, + PROJECT_SCOPE_MISMATCH_DETAIL, + ) + scope = _CANVAS_READ_SCOPE if mode == "read" else _CANVAS_WRITE_SCOPE + try: + cid = await check_agent_scope_for_project(request, scope, project_id) + except HTTPException as exc: + if exc.status_code == 403 and exc.detail == PROJECT_SCOPE_MISMATCH_DETAIL: + return JSONResponse({"error": "not found"}, status_code=404) + raise + if cid is None: + return JSONResponse({"error": "not found"}, status_code=404) + ps = request.app.state.project_store + member = await ps.get_member(project_id, cid) + flag = (member or {}).get( + "can_read_canvas" if mode == "read" else "can_edit_canvas" + ) + if not flag: + return JSONResponse( + { + "error": "permission_denied", + "message": f"agent {cid} lacks canvas {mode} access on {project_id}", + }, + status_code=403, + ) + return ("agent", cid) + + class CreateElementIn(BaseModel): kind: Literal[ "note", "link", "image", "user_shape", @@ -48,6 +111,9 @@ class CreateElementIn(BaseModel): async def list_canvas_elements( project_id: str, request: Request, element_id: str | None = None, ): + auth = await _authorize_canvas_actor(request, project_id, "read") + if isinstance(auth, JSONResponse): + return auth cs = request.app.state.project_canvas_store elements = await cs.list_elements(project_id, element_id=element_id) return {"elements": elements} @@ -57,6 +123,10 @@ async def list_canvas_elements( async def create_canvas_element( project_id: str, payload: CreateElementIn, request: Request, ): + auth = await _authorize_canvas_actor(request, project_id, "write") + if isinstance(auth, JSONResponse): + return auth + actor_kind, actor_id = auth cs = request.app.state.project_canvas_store element = payload.model_dump() element_id = element.pop("element_id", None) @@ -69,7 +139,7 @@ async def create_canvas_element( try: new_el = await cs.add_element( project_id=project_id, element=element, - author_kind="user", author_id=_user_id(request), + author_kind=actor_kind, author_id=actor_id, element_id=element_id, ) except ValueError as e: @@ -91,12 +161,16 @@ class PatchElementIn(BaseModel): async def update_canvas_element( project_id: str, element_id: str, payload: PatchElementIn, request: Request, ): + auth = await _authorize_canvas_actor(request, project_id, "write") + if isinstance(auth, JSONResponse): + return auth + actor_kind, actor_id = auth cs = request.app.state.project_canvas_store patch = {k: v for k, v in payload.model_dump().items() if v is not None} try: updated = await cs.update_element( project_id=project_id, element_id=element_id, patch=patch, - author_kind="user", author_id=_user_id(request), + author_kind=actor_kind, author_id=actor_id, ) except CanvasPermissionError as e: return JSONResponse({"error": "permission_denied", "message": str(e)}, status_code=403) @@ -107,11 +181,15 @@ async def update_canvas_element( @router.delete("/api/projects/{project_id}/canvas/elements/{element_id}", status_code=204) async def delete_canvas_element(project_id: str, element_id: str, request: Request): + auth = await _authorize_canvas_actor(request, project_id, "write") + if isinstance(auth, JSONResponse): + return auth + actor_kind, actor_id = auth cs = request.app.state.project_canvas_store try: await cs.delete_element( project_id=project_id, element_id=element_id, - author_kind="user", author_id=_user_id(request), + author_kind=actor_kind, author_id=actor_id, ) except CanvasPermissionError as e: return JSONResponse({"error": "permission_denied", "message": str(e)}, status_code=403) @@ -119,11 +197,15 @@ async def delete_canvas_element(project_id: str, element_id: str, request: Reque class PermissionIn(BaseModel): - can_edit_canvas: bool + can_read_canvas: bool | None = None + can_edit_canvas: bool | None = None @router.get("/api/projects/{project_id}/canvas/snapshot.png") async def get_canvas_png(project_id: str, request: Request): + auth = await _authorize_canvas_actor(request, project_id, "read") + if isinstance(auth, JSONResponse): + return auth cs = request.app.state.project_canvas_store elements = await cs.list_elements(project_id) project = await request.app.state.project_store.get_project(project_id) @@ -141,6 +223,9 @@ async def get_canvas_png(project_id: str, request: Request): @router.get("/api/projects/{project_id}/canvas/snapshot.tldr") async def get_canvas_tldr(project_id: str, request: Request): + auth = await _authorize_canvas_actor(request, project_id, "read") + if isinstance(auth, JSONResponse): + return auth snap = request.app.state.canvas_snapshotter path = await snap.export_now(project_id) if path is None or not path.exists(): @@ -153,29 +238,66 @@ async def set_canvas_permission( project_id: str, agent_id: str, payload: PermissionIn, request: Request, ): ps = request.app.state.project_store - val = 1 if payload.can_edit_canvas else 0 + project = await ps.get_project(project_id) + if project is None: + return JSONResponse({"error": "not found"}, status_code=404) + uid = getattr(request.state, "user_id", None) + is_admin = bool(getattr(request.state, "is_admin", False)) + if not uid or (not is_admin and project.get("user_id") != uid): + return JSONResponse( + { + "error": "forbidden", + "message": "only the project owner or an admin may change canvas permissions", + }, + status_code=403, + ) + sets: list[str] = [] + params: list = [] + if payload.can_read_canvas is not None: + sets.append("can_read_canvas = ?") + params.append(1 if payload.can_read_canvas else 0) + if payload.can_edit_canvas is not None: + sets.append("can_edit_canvas = ?") + params.append(1 if payload.can_edit_canvas else 0) + if not sets: + return JSONResponse({"error": "no permission field provided"}, status_code=400) + params.extend([project_id, agent_id]) cur = await ps._db.execute( - "UPDATE project_members SET can_edit_canvas = ? " + f"UPDATE project_members SET {', '.join(sets)} " "WHERE project_id = ? AND member_id = ?", - (val, project_id, agent_id), + params, ) await ps._db.commit() if cur.rowcount == 0: return JSONResponse({"error": "member not found"}, status_code=404) + member = await ps.get_member(project_id, agent_id) broker = request.app.state.project_event_broker from tinyagentos.projects.events import ProjectEvent await broker.publish( project_id, ProjectEvent( kind="canvas.permission_changed", - payload={"agent_id": agent_id, "can_edit_canvas": bool(val)}, + payload={ + "actor": {"kind": "user", "id": uid}, + "agent_id": agent_id, + "can_read_canvas": bool(member.get("can_read_canvas")), + "can_edit_canvas": bool(member.get("can_edit_canvas")), + }, ), ) - return {"ok": True, "agent_id": agent_id, "can_edit_canvas": bool(val)} + return { + "ok": True, + "agent_id": agent_id, + "can_read_canvas": bool(member.get("can_read_canvas")), + "can_edit_canvas": bool(member.get("can_edit_canvas")), + } @router.get("/api/projects/{project_id}/canvas/stream") async def canvas_stream(project_id: str, request: Request): + auth = await _authorize_canvas_actor(request, project_id, "read") + if isinstance(auth, JSONResponse): + return auth broker = request.app.state.project_event_broker queue = await broker.subscribe(project_id) From bffab44dfbfb61cc5bfd437d77a4820b918478cd Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 14 Jul 2026 15:02:17 +0100 Subject: [PATCH 2/2] fix(canvas): fold adversarial review findings 1, 2, 4 into route gating - Finding 1: add the PATCH /canvas/elements/{eid} route to the agent canvas allowlist so a canvas_write token can update elements; the PATCH permissions route stays owner/admin only. Flip the middleware allowlist test accordingly. - Finding 2: gate the session branch of _authorize_canvas_actor on project visibility (owner/admin). A non-owner collapses to the same existence-hiding 404 the agent path uses; owner/admin and user attribution are unchanged. - Finding 4: add a can_read_canvas store gate and enforce it on the in-process MCP read path (canvas_list_elements) so a write-only agent cannot read the board, mirroring the write flag gate. Docs-Reviewed: fold adversarial review findings per lead-agent-identity-and-canvas-access.md --- tests/projects/test_canvas_mcp_tools.py | 33 +++++++++++++ tests/test_auth_middleware.py | 6 ++- tests/test_routes_project_canvas.py | 59 ++++++++++++++++++++++++ tinyagentos/auth_middleware.py | 6 ++- tinyagentos/projects/canvas/mcp_tools.py | 22 ++++++++- tinyagentos/projects/canvas/store.py | 24 ++++++++++ tinyagentos/routes/project_canvas.py | 11 ++++- 7 files changed, 155 insertions(+), 6 deletions(-) diff --git a/tests/projects/test_canvas_mcp_tools.py b/tests/projects/test_canvas_mcp_tools.py index b572880b0..d37b4bcc9 100644 --- a/tests/projects/test_canvas_mcp_tools.py +++ b/tests/projects/test_canvas_mcp_tools.py @@ -135,6 +135,39 @@ async def test_canvas_add_denied_without_permission(env): assert res["error"] == "permission_denied" +@pytest.mark.asyncio +async def test_canvas_list_denied_without_read(env): + p, ctx, _ps, grant = env + # can_edit_canvas only: the read checkbox stays off, so the in-process + # read path must be blocked even though the agent can write. + await grant() + res = await ct.canvas_list_elements( + ctx, project_id=p["id"], agent_id="agent-1" + ) + assert res["error"] == "permission_denied" + + +@pytest.mark.asyncio +async def test_canvas_list_allowed_with_read(env): + p, ctx, ps, grant = env + await grant() + await ps._db.execute( + "UPDATE project_members SET can_read_canvas = 1 " + "WHERE project_id = ? AND member_id = ?", + (p["id"], "agent-1"), + ) + await ps._db.commit() + el = await ctx.canvas_store.add_element( + project_id=p["id"], author_kind="user", author_id="u", + element={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, "payload": {"text": "x"}}, + ) + res = await ct.canvas_list_elements( + ctx, project_id=p["id"], agent_id="agent-1" + ) + assert "elements" in res + assert [e["id"] for e in res["elements"]] == [el["id"]] + + @pytest.mark.asyncio async def test_canvas_update_denied_without_permission(env): p, ctx, _ps, _grant = env diff --git a/tests/test_auth_middleware.py b/tests/test_auth_middleware.py index f309492d4..e50bc7bda 100644 --- a/tests/test_auth_middleware.py +++ b/tests/test_auth_middleware.py @@ -289,8 +289,10 @@ def test_snapshot_tldr_allowed(self): def test_stream_allowed(self): assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/stream") is True - def test_update_element_patch_not_allowed(self): - assert _is_agent_canvas_path("PATCH", "/api/projects/proj-1/canvas/elements/el-1") is False + def test_update_element_patch_allowed(self): + # canvas_write-bound agents may PATCH an element (create + update + + # delete all live under canvas_write), so the route is on the allowlist. + assert _is_agent_canvas_path("PATCH", "/api/projects/proj-1/canvas/elements/el-1") is True def test_permissions_patch_not_allowed(self): assert _is_agent_canvas_path("PATCH", "/api/projects/proj-1/canvas/permissions/agent-1") is False diff --git a/tests/test_routes_project_canvas.py b/tests/test_routes_project_canvas.py index d4968ea70..6ea18105a 100644 --- a/tests/test_routes_project_canvas.py +++ b/tests/test_routes_project_canvas.py @@ -399,6 +399,45 @@ async def test_canvas_stream_endpoint_exists(client): assert "/api/projects/{project_id}/canvas/stream" in paths +# --------------------------------------------------------------------------- +# Finding 2 (adversarial review): session users must be gated by project +# visibility (owner/admin). A non-owner collapses into the same existence-hiding +# 404 the agent path uses (D3 matrix); owner/admin stays allowed, and an +# allowed session write is still attributed to the verified user. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestSessionCanvasOwnerGating: + async def test_owner_session_write_allowed(self, ctx): + pid = await _new_project(ctx, "owner-gate-write") + resp = await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "owned"}}, + ) + assert resp.status_code == 201, resp.text + + async def test_non_owner_session_list_is_404(self, ctx): + pid = await _new_project(ctx, "nonowner-gate") + async with _non_owner_client(ctx.app) as other: + resp = await other.get(f"/api/projects/{pid}/canvas/elements") + assert resp.status_code == 404 + assert resp.status_code != 403 + assert resp.status_code != 200 + + async def test_non_owner_session_write_is_404(self, ctx): + pid = await _new_project(ctx, "nonowner-gate-write") + async with _non_owner_client(ctx.app) as other: + resp = await other.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "x"}}, + ) + assert resp.status_code == 404 + assert resp.status_code != 403 + + # --------------------------------------------------------------------------- # Slice 3: agent scope + per-project canvas permission gating (D3 matrix) # plus honest attribution and the uniform actor stamp (D4). @@ -441,6 +480,26 @@ async def _new_project(ctx, slug): return resp.json()["id"] +def _non_owner_client(app): + """A non-owner, non-admin session client for a second human user. + + The project under test is owned by the admin `client` fixture user, so a + session minted for this user is an "other" human per the D3 matrix and must + collapse into an existence-hiding 404 on the canvas. + """ + auth = app.state.auth + code = auth.add_user_invite("other", "admin") + rec = auth.complete_invite( + "other", code, "Other User", "o@example.com", "password123" + ) + token = auth.create_session(user_id=rec["id"], long_lived=True) + return AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": token}, + ) + + async def _add_member(ctx, pid, member_id): await ctx.app.state.project_store.add_member(pid, member_id, member_kind="native") diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index 061cb2615..792f2608c 100644 --- a/tinyagentos/auth_middleware.py +++ b/tinyagentos/auth_middleware.py @@ -61,6 +61,7 @@ _AGENT_CANVAS_ROUTES = ( ("GET", re.compile(rf"^/api/projects/{_SEG}/canvas/elements$")), ("POST", re.compile(rf"^/api/projects/{_SEG}/canvas/elements$")), + ("PATCH", re.compile(rf"^/api/projects/{_SEG}/canvas/elements/{_SEG}$")), ("DELETE", re.compile(rf"^/api/projects/{_SEG}/canvas/elements/{_SEG}$")), ("GET", re.compile(rf"^/api/projects/{_SEG}/canvas/snapshot.png$")), ("GET", re.compile(rf"^/api/projects/{_SEG}/canvas/snapshot.tldr$")), @@ -76,8 +77,9 @@ def _is_agent_task_path(method: str, path: str) -> bool: def _is_agent_canvas_path(method: str, path: str) -> bool: """True only for the exact subset of canvas routes a canvas_read/canvas_write - token may reach. Strict method + anchored-regex match; the PATCH elements - and PATCH permissions routes stay session-only.""" + token may reach. Strict method + anchored-regex match; the PATCH permissions + route stays session-only (owner/admin only) but the PATCH elements route is + reachable by a canvas_write-bound agent token, mirroring POST/DELETE.""" return any(m == method and rx.match(path) for m, rx in _AGENT_CANVAS_ROUTES) # Bundle assets and the SPA shell HTML must be reachable without auth so: # 1. The browser can install and cache the shell for offline / PWA use. diff --git a/tinyagentos/projects/canvas/mcp_tools.py b/tinyagentos/projects/canvas/mcp_tools.py index ca1b755cb..3d1c8812f 100644 --- a/tinyagentos/projects/canvas/mcp_tools.py +++ b/tinyagentos/projects/canvas/mcp_tools.py @@ -30,7 +30,27 @@ class CanvasToolContext: data_root: Path -async def canvas_list_elements(ctx: CanvasToolContext, *, project_id: str) -> dict: +async def canvas_list_elements( + ctx: CanvasToolContext, *, project_id: str, agent_id: str +) -> dict: + """List canvas elements an agent can read, gated by can_read_canvas (D3). + + Surfaces the read-permission floor as a clean error, mirroring the other + in-process canvas tools so an agent without read access is told what to do + instead of silently receiving another principal's board contents.""" + try: + await ctx.canvas_store.check_read_permission( + project_id, "agent", agent_id + ) + except CanvasPermissionError: + return { + "error": "permission_denied", + "message": ( + "This agent does not have read permission on the canvas. " + "Ask the user to enable it in project settings, or message " + "them to grant access." + ), + } elements = await ctx.canvas_store.list_elements(project_id) return {"elements": elements} diff --git a/tinyagentos/projects/canvas/store.py b/tinyagentos/projects/canvas/store.py index fee2e2659..c627574f7 100644 --- a/tinyagentos/projects/canvas/store.py +++ b/tinyagentos/projects/canvas/store.py @@ -210,6 +210,30 @@ async def _check_edit_permission( f"agent {author_id} has no can_edit_canvas on project {project_id}" ) + async def check_read_permission( + self, project_id: str, author_kind: str, author_id: str + ) -> None: + """Gate canvas reads on can_read_canvas for agents (D3 read matrix). + + Mirrors _check_edit_permission: humans bypass the floor, agents must hold + the per-project can_read_canvas flag, and a missing member row fails + closed. This is the in-process analogue of the route-level canvas_read + scope check, guarding the agent MCP read path at the store floor.""" + if author_kind == "user": + return + if author_kind != "agent": + raise ValueError(f"invalid author_kind: {author_kind}") + async with self._db.execute( + "SELECT can_read_canvas FROM project_members " + "WHERE project_id = ? AND member_id = ?", + (project_id, author_id), + ) as cur: + row = await cur.fetchone() + if row is None or not row[0]: + raise CanvasPermissionError( + f"agent {author_id} has no can_read_canvas on project {project_id}" + ) + async def update_element( self, *, diff --git a/tinyagentos/routes/project_canvas.py b/tinyagentos/routes/project_canvas.py index 59fb20fef..b32bdff74 100644 --- a/tinyagentos/routes/project_canvas.py +++ b/tinyagentos/routes/project_canvas.py @@ -54,7 +54,16 @@ async def _authorize_canvas_actor( """ uid = getattr(request.state, "user_id", None) if uid: - # Session owner/admin: unchanged session behavior, attributed to the user. + # Session path: project visibility gate (D3). Only the project owner or + # an admin may touch the canvas; a human needs no scope and no checkbox, + # but a non-owner collapses into the SAME existence-hiding 404 the agent + # path uses (compare _get_owned_project on the task routes). Attribution + # stays the verified user. + ps = request.app.state.project_store + project = await ps.get_project(project_id) + is_admin = bool(getattr(request.state, "is_admin", False)) + if project is not None and not is_admin and project.get("user_id") != uid: + return JSONResponse({"error": "not found"}, status_code=404) return ("user", _user_id(request)) auth_header = request.headers.get("Authorization", "") if not auth_header.lower().startswith("bearer "):