From d5ff3c1cb74ce629421df6c3d66f4c76d760c256 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 14 Jul 2026 11:41:38 +0100 Subject: [PATCH 1/4] feat(agents): add canvas_read and canvas_write scopes Add canvas_read and canvas_write to VALID_SCOPES and _ALLOWED_SCOPES. Canvas scopes require an explicit project_id at approval time, matching the project_tasks guard. Approver cannot widen scopes beyond what the agent requested. Docs-Reviewed: scope vocab additions per design doc lead-agent-identity-and-canvas-access.md --- desktop/src/components/ConsentActions.tsx | 22 ++- tests/test_routes_agent_auth_requests.py | 210 +++++++++++++++++++++- tinyagentos/routes/agent_auth_requests.py | 30 +++- tinyagentos/routes/agent_registry.py | 1 + 4 files changed, 249 insertions(+), 14 deletions(-) diff --git a/desktop/src/components/ConsentActions.tsx b/desktop/src/components/ConsentActions.tsx index 6ec60f538..c92aaad91 100644 --- a/desktop/src/components/ConsentActions.tsx +++ b/desktop/src/components/ConsentActions.tsx @@ -15,7 +15,21 @@ import { CheckCircle, XCircle } from "lucide-react"; * right project at approval time. The chosen project id is passed to the approve * endpoint, which mints the token bound to it. */ -const PROJECT_SCOPE = "project_tasks"; +const PROJECT_SCOPES = new Set(["project_tasks", "canvas_read", "canvas_write"]); + +const SCOPE_LABELS: Record = { + memory_read: "Read agent memory", + memory_write: "Write agent memory", + a2a_send: "Send messages to other agents", + a2a_receive: "Receive messages from other agents", + files_read: "Read files", + files_write: "Write files", + tools_execute: "Execute tools", + registry_feeds_read: "Read registry feeds", + project_tasks: "Read and manage project tasks", + canvas_read: "Read project canvas", + canvas_write: "Write to project canvas", +}; interface ProjectOption { id: string; @@ -43,7 +57,7 @@ export function ConsentActions({ requestedProjectId?: string; onResolved?: () => void; }) { - const needsProject = scopes.includes(PROJECT_SCOPE); + const needsProject = scopes.some((s) => PROJECT_SCOPES.has(s)); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [projects, setProjects] = useState([]); @@ -128,7 +142,7 @@ export function ConsentActions({ projectId = created; } if (approved && needsProject && !projectId) { - setError("Select or create a project to grant project_tasks."); + setError("Select or create a project for the requested project access."); setBusy(false); return; } @@ -172,7 +186,7 @@ export function ConsentActions({ htmlFor={`consent-project-${requestId}`} className="block text-[11px] text-shell-text-secondary mb-1" > - Grant project_tasks for project + Grant project access for {!creating ? (
diff --git a/tests/test_routes_agent_auth_requests.py b/tests/test_routes_agent_auth_requests.py index 9ef0b6f65..c4def4688 100644 --- a/tests/test_routes_agent_auth_requests.py +++ b/tests/test_routes_agent_auth_requests.py @@ -316,7 +316,215 @@ async def test_approve_bare_at_claim_falls_back_to_framework( await grants.close() -class TestAgentAuthRequestsGet: +class TestCanvasScopeApproval: + """Canvas scopes require a project_id and follow the same narrow-not-widen + rules as project_tasks.""" + + @pytest.mark.asyncio + async def test_approve_canvas_scopes_with_project_succeeds( + self, client, monkeypatch, tmp_path + ): + from tinyagentos.agent_registry_store import ( + AgentRegistryStore, + load_or_create_signing_keypair, + ) + from tinyagentos.auth_requests_store import AuthRequestsStore + from tinyagentos.agent_grants_store import AgentGrantsStore + + registry = AgentRegistryStore(tmp_path / "reg-canvas.db") + await registry.init() + auth_store = AuthRequestsStore(tmp_path / "auth-canvas.db") + await auth_store.init() + grants = AgentGrantsStore(tmp_path / "grants-canvas.db") + await grants.init() + priv, pub = load_or_create_signing_keypair(tmp_path / "keys-canvas") + + record = await auth_store.create( + identity_claim="canvas-bot", + framework="canvas-cli", + requested_scopes=["canvas_read", "canvas_write"], + requested_skills=None, + reason="", + duration_secs=None, + project_id="prj-canvas-1", + ) + + monkeypatch.setattr(client._transport.app.state, "agent_registry", registry) + monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store) + monkeypatch.setattr(client._transport.app.state, "agent_grants", grants) + monkeypatch.setattr( + client._transport.app.state, "agent_registry_keypair", (priv, pub) + ) + + resp = await client.post( + f"/api/agents/auth-requests/{record['id']}/approve", + json={ + "granted_scopes": ["canvas_read", "canvas_write"], + "project_id": "prj-canvas-1", + }, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["status"] == "accepted" + assert data["canonical_id"] + + agents = await registry.list_all() + assert len(agents) == 1 + agent_grants = await grants.list_grants(agents[0]["canonical_id"]) + assert {g["scope"] for g in agent_grants} == {"canvas_read", "canvas_write"} + + await registry.close() + await auth_store.close() + await grants.close() + + @pytest.mark.asyncio + async def test_approve_canvas_scopes_without_project_is_rejected( + self, client, monkeypatch, tmp_path + ): + from tinyagentos.agent_registry_store import ( + AgentRegistryStore, + load_or_create_signing_keypair, + ) + from tinyagentos.auth_requests_store import AuthRequestsStore + from tinyagentos.agent_grants_store import AgentGrantsStore + + registry = AgentRegistryStore(tmp_path / "reg-canvas-np.db") + await registry.init() + auth_store = AuthRequestsStore(tmp_path / "auth-canvas-np.db") + await auth_store.init() + grants = AgentGrantsStore(tmp_path / "grants-canvas-np.db") + await grants.init() + priv, pub = load_or_create_signing_keypair(tmp_path / "keys-canvas-np") + + record = await auth_store.create( + identity_claim="canvas-bot", + framework="canvas-cli", + requested_scopes=["canvas_read", "canvas_write"], + requested_skills=None, + reason="", + duration_secs=None, + project_id="prj-canvas-1", + ) + + monkeypatch.setattr(client._transport.app.state, "agent_registry", registry) + monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store) + monkeypatch.setattr(client._transport.app.state, "agent_grants", grants) + monkeypatch.setattr( + client._transport.app.state, "agent_registry_keypair", (priv, pub) + ) + + resp = await client.post( + f"/api/agents/auth-requests/{record['id']}/approve", + json={"granted_scopes": ["canvas_read", "canvas_write"]}, + ) + assert resp.status_code == 400, resp.text + assert "project_id" in resp.text + + # No agent should have been registered by the rejected approval. + assert await registry.list_all() == [] + + await registry.close() + await auth_store.close() + await grants.close() + + @pytest.mark.asyncio + async def test_approve_cannot_widen_canvas_scopes( + self, client, monkeypatch, tmp_path + ): + from tinyagentos.agent_registry_store import ( + AgentRegistryStore, + load_or_create_signing_keypair, + ) + from tinyagentos.auth_requests_store import AuthRequestsStore + from tinyagentos.agent_grants_store import AgentGrantsStore + + registry = AgentRegistryStore(tmp_path / "reg-canvas-widen.db") + await registry.init() + auth_store = AuthRequestsStore(tmp_path / "auth-canvas-widen.db") + await auth_store.init() + grants = AgentGrantsStore(tmp_path / "grants-canvas-widen.db") + await grants.init() + priv, pub = load_or_create_signing_keypair(tmp_path / "keys-canvas-widen") + + record = await auth_store.create( + identity_claim="canvas-bot", + framework="canvas-cli", + requested_scopes=["canvas_read"], + requested_skills=None, + reason="", + duration_secs=None, + project_id="prj-canvas-1", + ) + + monkeypatch.setattr(client._transport.app.state, "agent_registry", registry) + monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store) + monkeypatch.setattr(client._transport.app.state, "agent_grants", grants) + monkeypatch.setattr( + client._transport.app.state, "agent_registry_keypair", (priv, pub) + ) + + resp = await client.post( + f"/api/agents/auth-requests/{record['id']}/approve", + json={ + "granted_scopes": ["canvas_read", "canvas_write"], + "project_id": "prj-canvas-1", + }, + ) + assert resp.status_code == 400, resp.text + assert "subset of the requested scopes" in resp.text + + await registry.close() + await auth_store.close() + await grants.close() + + @pytest.mark.asyncio + async def test_approve_canvas_scopes_cannot_widen_by_adding_project_tasks( + self, client, monkeypatch, tmp_path + ): + from tinyagentos.agent_registry_store import ( + AgentRegistryStore, + load_or_create_signing_keypair, + ) + from tinyagentos.auth_requests_store import AuthRequestsStore + from tinyagentos.agent_grants_store import AgentGrantsStore + + registry = AgentRegistryStore(tmp_path / "reg-canvas-mix.db") + await registry.init() + auth_store = AuthRequestsStore(tmp_path / "auth-canvas-mix.db") + await auth_store.init() + grants = AgentGrantsStore(tmp_path / "grants-canvas-mix.db") + await grants.init() + priv, pub = load_or_create_signing_keypair(tmp_path / "keys-canvas-mix") + + record = await auth_store.create( + identity_claim="canvas-bot", + framework="canvas-cli", + requested_scopes=["canvas_read"], + requested_skills=None, + reason="", + duration_secs=None, + project_id="prj-canvas-1", + ) + + monkeypatch.setattr(client._transport.app.state, "agent_registry", registry) + monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store) + monkeypatch.setattr(client._transport.app.state, "agent_grants", grants) + monkeypatch.setattr( + client._transport.app.state, "agent_registry_keypair", (priv, pub) + ) + + resp = await client.post( + f"/api/agents/auth-requests/{record['id']}/approve", + json={ + "granted_scopes": ["canvas_read", "project_tasks"], + "project_id": "prj-canvas-1", + }, + ) + assert resp.status_code == 400, resp.text + + await registry.close() + await auth_store.close() + await grants.close() @pytest.mark.asyncio async def test_get_unknown_id_returns_404(self, client, monkeypatch): store = _FakeAuthRequestsStore() diff --git a/tinyagentos/routes/agent_auth_requests.py b/tinyagentos/routes/agent_auth_requests.py index 4e4349e3e..4d06b37a3 100644 --- a/tinyagentos/routes/agent_auth_requests.py +++ b/tinyagentos/routes/agent_auth_requests.py @@ -54,6 +54,12 @@ # agent's OWN project only (bound by the token's project_id claim). Does NOT # grant task create, member management, or project lifecycle. "project_tasks", + # Canvas access: read and write on a specific project's canvas. Like + # project_tasks, a project_id is required so the token is bound to the + # operator-validated project rather than whatever the unauthenticated agent + # named in the request. + "canvas_read", + "canvas_write", }) @@ -298,17 +304,23 @@ async def _do_approve(request: Request, request_id: str, body: ApproveBody, user ), ) - # project_tasks binds the token to a specific project and adds a membership - # row, so require the human to pick that project explicitly in the consent - # card. Never fall back to the agent-supplied project_id for a task grant: - # POST /api/agents/auth-requests is unauthenticated, so the request could - # name any existing project the operator never validated. Other scopes keep - # the fallback so global tokens still work. Checked before any registration - # so a rejected approval never leaves an orphaned agent. - if "project_tasks" in body.granted_scopes and body.project_id is None: + _CANVAS_SCOPES = {"canvas_read", "canvas_write"} + _PROJECT_SCOPES = {"project_tasks"} | _CANVAS_SCOPES + + # project_tasks and the canvas scopes bind the token to a specific project + # and add a membership row, so require the human to pick that project + # explicitly in the consent card. Never fall back to the agent-supplied + # project_id for these grants: POST /api/agents/auth-requests is + # unauthenticated, so the request could name any existing project the + # operator never validated. Other scopes keep the fallback so global + # tokens still work. Checked before any registration so a rejected approval + # never leaves an orphaned agent. + needs_project = bool(set(body.granted_scopes) & _PROJECT_SCOPES) + if needs_project and body.project_id is None: + missing = sorted(set(body.granted_scopes) & _PROJECT_SCOPES) raise HTTPException( status_code=400, - detail="project_id is required when granting project_tasks", + detail=f"project_id is required when granting {missing}", ) registry = _get_registry_store(request) diff --git a/tinyagentos/routes/agent_registry.py b/tinyagentos/routes/agent_registry.py index 59597a484..e5c8e4576 100644 --- a/tinyagentos/routes/agent_registry.py +++ b/tinyagentos/routes/agent_registry.py @@ -92,6 +92,7 @@ class OrgUpdateRequest(BaseModel): "files_read", "files_write", "tools_execute", "registry_feeds_read", "project_tasks", + "canvas_read", "canvas_write", }) From 9874328a39eed3ebba67cb4613ad048b40a23ab0 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 14 Jul 2026 11:50:54 +0100 Subject: [PATCH 2/4] fix(agents): drop unused SCOPE_LABELS map in ConsentActions The scope label map was added but never rendered (no scope-label site in this component; that belongs to the Members UI in a later slice), which failed the strict SPA build with an unused-declaration error. Remove it; the canvas scopes already drive the project picker via needsProject. Docs-Reviewed: build fix only, no behaviour change, scope vocab per lead-agent-identity-and-canvas-access.md --- desktop/src/components/ConsentActions.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/desktop/src/components/ConsentActions.tsx b/desktop/src/components/ConsentActions.tsx index c92aaad91..b8684edae 100644 --- a/desktop/src/components/ConsentActions.tsx +++ b/desktop/src/components/ConsentActions.tsx @@ -17,20 +17,6 @@ import { CheckCircle, XCircle } from "lucide-react"; */ const PROJECT_SCOPES = new Set(["project_tasks", "canvas_read", "canvas_write"]); -const SCOPE_LABELS: Record = { - memory_read: "Read agent memory", - memory_write: "Write agent memory", - a2a_send: "Send messages to other agents", - a2a_receive: "Receive messages from other agents", - files_read: "Read files", - files_write: "Write files", - tools_execute: "Execute tools", - registry_feeds_read: "Read registry feeds", - project_tasks: "Read and manage project tasks", - canvas_read: "Read project canvas", - canvas_write: "Write to project canvas", -}; - interface ProjectOption { id: string; name: string; From 297fd187a868171615225ffc039ab6b25b28910f Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 14 Jul 2026 12:52:03 +0100 Subject: [PATCH 3/4] fix(agents): reject blank project_id for project-scoped grants Adversarial review flagged that the project-binding guard only checked 'is None', so an empty or whitespace project_id passed through and minted a token with a falsy project_id that downstream truthy checks treat as unbound, opening a cross-project canvas write. Fail closed on None, empty, and whitespace, and add a regression test for both blank forms. Docs-Reviewed: defense-in-depth on the canvas scope project binding per lead-agent-identity-and-canvas-access.md --- tests/test_routes_agent_auth_requests.py | 55 +++++++++++++++++++++++ tinyagentos/routes/agent_auth_requests.py | 5 ++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/test_routes_agent_auth_requests.py b/tests/test_routes_agent_auth_requests.py index c4def4688..d59b0d32e 100644 --- a/tests/test_routes_agent_auth_requests.py +++ b/tests/test_routes_agent_auth_requests.py @@ -427,6 +427,61 @@ async def test_approve_canvas_scopes_without_project_is_rejected( await auth_store.close() await grants.close() + @pytest.mark.asyncio + async def test_approve_canvas_scopes_with_blank_project_is_rejected( + self, client, monkeypatch, tmp_path + ): + # A blank or whitespace project_id is not a real binding: it must fail + # closed exactly like a missing one, or a downstream truthy check would + # treat the token as unbound and allow cross-project canvas access. + from tinyagentos.agent_registry_store import ( + AgentRegistryStore, + load_or_create_signing_keypair, + ) + from tinyagentos.auth_requests_store import AuthRequestsStore + from tinyagentos.agent_grants_store import AgentGrantsStore + + for blank in ("", " "): + registry = AgentRegistryStore(tmp_path / f"reg-canvas-blank-{len(blank)}.db") + await registry.init() + auth_store = AuthRequestsStore(tmp_path / f"auth-canvas-blank-{len(blank)}.db") + await auth_store.init() + grants = AgentGrantsStore(tmp_path / f"grants-canvas-blank-{len(blank)}.db") + await grants.init() + priv, pub = load_or_create_signing_keypair( + tmp_path / f"keys-canvas-blank-{len(blank)}" + ) + + record = await auth_store.create( + identity_claim="canvas-bot", + framework="canvas-cli", + requested_scopes=["canvas_write"], + requested_skills=None, + reason="", + duration_secs=None, + project_id="prj-canvas-1", + ) + + monkeypatch.setattr(client._transport.app.state, "agent_registry", registry) + monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store) + monkeypatch.setattr(client._transport.app.state, "agent_grants", grants) + monkeypatch.setattr( + client._transport.app.state, "agent_registry_keypair", (priv, pub) + ) + + resp = await client.post( + f"/api/agents/auth-requests/{record['id']}/approve", + json={"granted_scopes": ["canvas_write"], "project_id": blank}, + ) + assert resp.status_code == 400, (blank, resp.text) + assert "project_id" in resp.text + # The blank-project approval must not register an agent. + assert await registry.list_all() == [] + + await registry.close() + await auth_store.close() + await grants.close() + @pytest.mark.asyncio async def test_approve_cannot_widen_canvas_scopes( self, client, monkeypatch, tmp_path diff --git a/tinyagentos/routes/agent_auth_requests.py b/tinyagentos/routes/agent_auth_requests.py index 4d06b37a3..e558ecd8f 100644 --- a/tinyagentos/routes/agent_auth_requests.py +++ b/tinyagentos/routes/agent_auth_requests.py @@ -316,7 +316,10 @@ async def _do_approve(request: Request, request_id: str, body: ApproveBody, user # tokens still work. Checked before any registration so a rejected approval # never leaves an orphaned agent. needs_project = bool(set(body.granted_scopes) & _PROJECT_SCOPES) - if needs_project and body.project_id is None: + # Reject None, "", and whitespace-only: a blank project_id is not a real + # binding, and a downstream truthy check would treat it as unbound, so an + # empty string must fail closed exactly like a missing one. + if needs_project and not (body.project_id and body.project_id.strip()): missing = sorted(set(body.granted_scopes) & _PROJECT_SCOPES) raise HTTPException( status_code=400, From 81a84c4748951c227a9025225c5d87b086b415da Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 15 Jul 2026 00:50:46 +0100 Subject: [PATCH 4/4] test(agents): update ConsentActions test to the new project-picker label Slice 1 renamed the picker label from 'Grant project_tasks for project' to 'Grant project access for' (the label now covers canvas scopes too), but the component test still looked the picker up by the old label, so three cases failed in the SPA build vitest run. Point them at the new label. Test-only change. Docs-Reviewed: test label sync for the project-picker rename in slice 1 per lead-agent-identity-and-canvas-access.md --- desktop/src/components/ConsentActions.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/components/ConsentActions.test.tsx b/desktop/src/components/ConsentActions.test.tsx index 0924aaa6a..bda6dd1fe 100644 --- a/desktop/src/components/ConsentActions.test.tsx +++ b/desktop/src/components/ConsentActions.test.tsx @@ -97,7 +97,7 @@ describe("ConsentActions", () => { , ); // The picker appears and the single project is auto-selected. - await screen.findByLabelText(/Grant project_tasks for project/i); + await screen.findByLabelText(/Grant project access for/i); fireEvent.click(screen.getByRole("button", { name: /allow/i })); await waitFor(() => expect(onResolved).toHaveBeenCalledTimes(1)); @@ -125,7 +125,7 @@ describe("ConsentActions", () => { vi.stubGlobal("fetch", fetchMock); render(); - const select = await screen.findByLabelText(/Grant project_tasks for project/i); + const select = await screen.findByLabelText(/Grant project access for/i); expect(screen.getByRole("button", { name: /allow/i })).toBeDisabled(); fireEvent.change(select, { target: { value: "p2" } }); @@ -151,7 +151,7 @@ describe("ConsentActions", () => { const onResolved = vi.fn(); render(); - await screen.findByLabelText(/Grant project_tasks for project/i); + await screen.findByLabelText(/Grant project access for/i); expect(screen.getByRole("button", { name: /allow/i })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: /new/i }));