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 })); diff --git a/desktop/src/components/ConsentActions.tsx b/desktop/src/components/ConsentActions.tsx index 6ec60f538..b8684edae 100644 --- a/desktop/src/components/ConsentActions.tsx +++ b/desktop/src/components/ConsentActions.tsx @@ -15,7 +15,7 @@ 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"]); interface ProjectOption { id: string; @@ -43,7 +43,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 +128,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 +172,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..d59b0d32e 100644 --- a/tests/test_routes_agent_auth_requests.py +++ b/tests/test_routes_agent_auth_requests.py @@ -316,7 +316,270 @@ 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_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 + ): + 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..e558ecd8f 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,26 @@ 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) + # 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, - 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", })