([]);
@@ -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",
})