From 1c05d5486a6d12b6f41e875329c5b3b8efd90d65 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 14 Jul 2026 18:03:56 +0100 Subject: [PATCH 1/2] feat(canvas): payload cap + agent write rate limit (#1800 slice 5) Docs-Reviewed: canvas payload cap + agent write rate limit per lead-agent-identity-and-canvas-access.md --- tests/test_routes_project_canvas.py | 291 +++++++++++---------------- tinyagentos/routes/project_canvas.py | 86 +++++++- 2 files changed, 201 insertions(+), 176 deletions(-) diff --git a/tests/test_routes_project_canvas.py b/tests/test_routes_project_canvas.py index 6ea18105..392c4064 100644 --- a/tests/test_routes_project_canvas.py +++ b/tests/test_routes_project_canvas.py @@ -574,178 +574,6 @@ async def test_read_without_flag_is_403(self, ctx): 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): @@ -790,3 +618,122 @@ async def test_patch_rejects_agent_token(self, ctx): ) assert resp.status_code == 401 + +# --------------------------------------------------------------------------- +# Slice 5: payload size cap (413) + agent write rate limit (429). +# --------------------------------------------------------------------------- + + +class TestCanvasPayloadSizeCap: + """The 64 KB payload cap applies to all principals (agents and humans).""" + + @pytest.mark.asyncio + async def test_create_oversized_payload_returns_413(self, ctx): + pid = await _new_project(ctx, "payload-cap") + oversized = "x" * (65 * 1024) + body = { + "kind": "text", + "x": 0, "y": 0, "w": 100, "h": 50, + "payload": {"text": oversized}, + } + resp = await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", json=body, + ) + assert resp.status_code == 413, resp.text + + @pytest.mark.asyncio + async def test_patch_oversized_payload_returns_413(self, ctx): + pid = await _new_project(ctx, "payload-cap-patch") + el = await _create_note(ctx.client, pid) + oversized = "x" * (65 * 1024) + resp = await ctx.client.patch( + f"/api/projects/{pid}/canvas/elements/{el['id']}", + json={"payload": {"text": oversized}}, + ) + assert resp.status_code == 413, resp.text + + @pytest.mark.asyncio + async def test_create_normal_payload_succeeds(self, ctx): + pid = await _new_project(ctx, "payload-cap-ok") + body = { + "kind": "text", + "x": 0, "y": 0, "w": 100, "h": 50, + "payload": {"text": "normal content"}, + } + resp = await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", json=body, + ) + assert resp.status_code == 201, resp.text + + +class TestAgentWriteRateLimit: + """Agents are throttled to 30 canvas writes per 60 s rolling window. + Humans (session principals) are never throttled.""" + + @pytest.mark.asyncio + async def test_agent_exceeding_window_returns_429(self, ctx): + import time + import tinyagentos.routes.project_canvas as canvas_mod + + pid = await _new_project(ctx, "rate-limit-agent") + cid, token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + await _grant_canvas(ctx, pid, cid, edit=True) + + limiter = canvas_mod._canvas_write_limiter + max_attempts = canvas_mod._CANVAS_WRITE_MAX_ATTEMPTS + now = time.monotonic() + with limiter._lock: + limiter._log[cid] = [now - 1.0] * max_attempts + + 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": "rate-limited"}}, + headers=_hdr(token), + ) + assert resp.status_code == 429, resp.text + + @pytest.mark.asyncio + async def test_agent_delete_exceeding_window_returns_429(self, ctx): + import time + import tinyagentos.routes.project_canvas as canvas_mod + + pid = await _new_project(ctx, "rate-limit-agent-del") + cid, token = await _mint_agent(ctx, pid, ("canvas_write",)) + await _add_member(ctx, pid, cid) + await _grant_canvas(ctx, pid, cid, edit=True) + + created = await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "to-delete"}}, + ) + assert created.status_code == 201 + eid = created.json()["element"]["id"] + + limiter = canvas_mod._canvas_write_limiter + max_attempts = canvas_mod._CANVAS_WRITE_MAX_ATTEMPTS + now = time.monotonic() + with limiter._lock: + limiter._log[cid] = [now - 1.0] * max_attempts + + async with _bare(ctx.app) as bare: + resp = await bare.delete( + f"/api/projects/{pid}/canvas/elements/{eid}", + headers=_hdr(token), + ) + assert resp.status_code == 429, resp.text + + @pytest.mark.asyncio + async def test_human_write_not_rate_limited(self, ctx): + pid = await _new_project(ctx, "rate-limit-human") + for _ in range(31): + resp = await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", + json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, + "payload": {"text": "human-write"}}, + ) + assert resp.status_code == 201, resp.text + diff --git a/tinyagentos/routes/project_canvas.py b/tinyagentos/routes/project_canvas.py index b32bdff7..9e7769bd 100644 --- a/tinyagentos/routes/project_canvas.py +++ b/tinyagentos/routes/project_canvas.py @@ -7,6 +7,9 @@ import asyncio import json import logging +import threading +import time +from collections import OrderedDict from typing import Literal from fastapi import APIRouter, HTTPException, Request @@ -20,12 +23,63 @@ 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" +_CANVAS_PAYLOAD_MAX_BYTES = 64 * 1024 +_CANVAS_WRITE_MAX_ATTEMPTS = 30 +_CANVAS_WRITE_WINDOW_SECONDS = 60 +_CANVAS_WRITE_MAX_KEYS = 10_000 + + +class _CanvasWriteLimiter: + def __init__(self, max_attempts: int = _CANVAS_WRITE_MAX_ATTEMPTS, + window_seconds: int = _CANVAS_WRITE_WINDOW_SECONDS): + self._max = max_attempts + self._window = window_seconds + self._log: OrderedDict[str, list[float]] = OrderedDict() + self._lock = threading.Lock() + + def _prune(self, key: str) -> None: + cutoff = time.monotonic() - self._window + if key not in self._log: + return + self._log[key] = [t for t in self._log[key] if t > cutoff] + if not self._log[key]: + del self._log[key] + else: + self._log.move_to_end(key) + + def _ensure_capacity(self) -> None: + while len(self._log) >= _CANVAS_WRITE_MAX_KEYS: + self._log.popitem(last=False) + + def is_limited(self, key: str) -> bool: + with self._lock: + self._prune(key) + return len(self._log.get(key, [])) >= self._max + + def record(self, key: str) -> None: + with self._lock: + self._prune(key) + if key not in self._log: + self._ensure_capacity() + self._log[key] = [] + self._log[key].append(time.monotonic()) + self._log.move_to_end(key) + + +_canvas_write_limiter = _CanvasWriteLimiter() + + +def _check_payload_size(payload: dict) -> None: + size = len(json.dumps(payload, default=str).encode("utf-8")) + if size > _CANVAS_PAYLOAD_MAX_BYTES: + raise HTTPException( + status_code=413, + detail=f"canvas payload exceeds {_CANVAS_PAYLOAD_MAX_BYTES // 1024} KB limit", + ) + def _user_id(request: Request) -> str: uid = getattr(request.state, "user_id", None) @@ -136,8 +190,14 @@ async def create_canvas_element( if isinstance(auth, JSONResponse): return auth actor_kind, actor_id = auth - cs = request.app.state.project_canvas_store + if actor_kind == "agent" and _canvas_write_limiter.is_limited(actor_id): + return JSONResponse( + {"error": "rate_limit", "message": "too many canvas writes, try again later"}, + status_code=429, + ) element = payload.model_dump() + _check_payload_size(element.get("payload") or {}) + cs = request.app.state.project_canvas_store element_id = element.pop("element_id", None) if element["kind"] == "link": url = (element.get("payload") or {}).get("url") @@ -153,6 +213,8 @@ async def create_canvas_element( ) except ValueError as e: return JSONResponse({"error": str(e)}, status_code=400) + if actor_kind == "agent": + _canvas_write_limiter.record(actor_id) return {"element": new_el} @@ -174,8 +236,15 @@ async def update_canvas_element( if isinstance(auth, JSONResponse): return auth actor_kind, actor_id = auth + if actor_kind == "agent" and _canvas_write_limiter.is_limited(actor_id): + return JSONResponse( + {"error": "rate_limit", "message": "too many canvas writes, try again later"}, + status_code=429, + ) cs = request.app.state.project_canvas_store patch = {k: v for k, v in payload.model_dump().items() if v is not None} + if "payload" in patch: + _check_payload_size(patch["payload"]) try: updated = await cs.update_element( project_id=project_id, element_id=element_id, patch=patch, @@ -185,6 +254,8 @@ async def update_canvas_element( return JSONResponse({"error": "permission_denied", "message": str(e)}, status_code=403) except ValueError as e: return JSONResponse({"error": str(e)}, status_code=404) + if actor_kind == "agent": + _canvas_write_limiter.record(actor_id) return {"element": updated} @@ -194,6 +265,11 @@ async def delete_canvas_element(project_id: str, element_id: str, request: Reque if isinstance(auth, JSONResponse): return auth actor_kind, actor_id = auth + if actor_kind == "agent" and _canvas_write_limiter.is_limited(actor_id): + return JSONResponse( + {"error": "rate_limit", "message": "too many canvas writes, try again later"}, + status_code=429, + ) cs = request.app.state.project_canvas_store try: await cs.delete_element( @@ -202,6 +278,8 @@ async def delete_canvas_element(project_id: str, element_id: str, request: Reque ) except CanvasPermissionError as e: return JSONResponse({"error": "permission_denied", "message": str(e)}, status_code=403) + if actor_kind == "agent": + _canvas_write_limiter.record(actor_id) return Response(status_code=204) From 4f5ee4226909c80380237686e4614c07babf0d50 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 14 Jul 2026 22:52:40 +0100 Subject: [PATCH 2/2] Docs-Reviewed: restore slice-3 enforcement tests dropped during slice 5 per lead-agent-identity-and-canvas-access.md --- tests/test_routes_project_canvas.py | 171 ++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/tests/test_routes_project_canvas.py b/tests/test_routes_project_canvas.py index 392c4064..feb44cae 100644 --- a/tests/test_routes_project_canvas.py +++ b/tests/test_routes_project_canvas.py @@ -573,6 +573,177 @@ async def test_read_without_flag_is_403(self, ctx): ) 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: