Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions desktop/src/components/ConsentActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe("ConsentActions", () => {
<ConsentActions requestId="req-p" scopes={["project_tasks", "a2a_send"]} onResolved={onResolved} />,
);
// 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));
Expand Down Expand Up @@ -125,7 +125,7 @@ describe("ConsentActions", () => {
vi.stubGlobal("fetch", fetchMock);
render(<ConsentActions requestId="req-m" scopes={["project_tasks"]} />);

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" } });
Expand All @@ -151,7 +151,7 @@ describe("ConsentActions", () => {
const onResolved = vi.fn();
render(<ConsentActions requestId="req-c" scopes={["project_tasks"]} onResolved={onResolved} />);

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 }));
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/components/ConsentActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string | null>(null);
const [projects, setProjects] = useState<ProjectOption[]>([]);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Incomplete label text.

"Grant project access for" reads as a truncated sentence with no object. Consider "Project" or "Grant project access for this request".

✏️ Proposed fix
-            Grant project access for
+            Project
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Grant project access for
Project
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src/components/ConsentActions.tsx` at line 189, Update the incomplete
label text near the ConsentActions component to use a complete, clear phrase,
such as “Project” or “Grant project access for this request,” instead of ending
with “for.”

</label>
{!creating ? (
<div className="flex items-center gap-1.5">
Expand Down
265 changes: 264 additions & 1 deletion tests/test_routes_agent_auth_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
33 changes: 24 additions & 9 deletions tinyagentos/routes/agent_auth_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})


Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Comment overstates current behavior — canvas scopes do NOT add a membership row.

This comment claims "project_tasks and the canvas scopes ... add a membership row", but the actual membership + a2a channel join at line 381 only triggers for "project_tasks" in body.granted_scopes. A canvas_read/canvas_write approval therefore gets a project-bound token and grant rows, but the agent is never added as a project member and never joins the a2a channel. Either extend the membership condition at line 381 to include canvas scopes, or correct this comment so it does not document behavior that does not exist yet.

# 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()):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Guard strips for emptiness but the bound project_id is stored unstripped

This rejects whitespace-only ids (e.g. " ") but accepts a non-empty, whitespace-padded id such as " prj-canvas-1 ", since both body.project_id and body.project_id.strip() are truthy. Downstream, effective_project (line 360) is assigned body.project_id verbatim and used unchanged for the minted token, grant rows, and the membership sync. A padded value therefore won't match the real project id and can bind the agent to a non-existent project. Consider normalizing (.strip()) body.project_id once before it is stored, or tightening this check to also reject padded ids.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The 400 detail renders a raw Python list repr (e.g. project_id is required when granting ['canvas_read', 'canvas_write']). Prefer a human-readable join.

Suggested change
detail=f"project_id is required when granting {missing}",
detail=f"project_id is required when granting: {', '.join(missing)}",

)
Comment on lines +307 to 327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'project_tasks.*granted_scopes|granted_scopes.*project_tasks' tinyagentos/routes/agent_auth_requests.py -B5 -A15

Repository: jaylfc/taOS

Length of output: 1233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant section of the approval flow.
sed -n '300,410p' tinyagentos/routes/agent_auth_requests.py

printf '\n---\n'

# Find canvas scope handling elsewhere in the repo.
rg -n '"canvas_read"|"canvas_write"|canvas_scopes|canvas' tinyagentos -g '!**/__pycache__/**'

Repository: jaylfc/taOS

Length of output: 30822


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the project canvas routes for how canvas_read/canvas_write are enforced.
sed -n '1,260p' tinyagentos/routes/project_canvas.py

printf '\n---\n'

# Inspect project canvas store permission checks around can_edit_canvas and member rows.
sed -n '1,320p' tinyagentos/projects/canvas/store.py

printf '\n---\n'

# Find where project membership is used as the authoritative signal for canvas access.
rg -n 'can_edit_canvas|project_members|add_member\(|a2a channel|membership' tinyagentos/routes tinyagentos/projects -g '!**/__pycache__/**'

Repository: jaylfc/taOS

Length of output: 22308


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the agent-facing canvas tools to see whether they depend on project membership.
sed -n '1,260p' tinyagentos/projects/canvas/mcp_tools.py

printf '\n---\n'

# Inspect project store schema and add_member behavior for the member row fields.
sed -n '1,280p' tinyagentos/projects/project_store.py

Repository: jaylfc/taOS

Length of output: 17998


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect project a2a membership resolution and channel sync behavior.
sed -n '1,260p' tinyagentos/projects/a2a.py

printf '\n---\n'

# Show the scope definitions at the top of agent_auth_requests.py.
sed -n '1,90p' tinyagentos/routes/agent_auth_requests.py

Repository: jaylfc/taOS

Length of output: 12101


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any other place where canvas scopes or project_tasks map to member permissions.
rg -n 'canvas_read|canvas_write|can_edit_canvas|project_tasks' tinyagentos -g '!**/__pycache__/**'

printf '\n---\n'

# Inspect any approval or grant logic around project member permissions.
rg -n 'add_member\(|can_edit_canvas =|set_canvas_permission|ensure_a2a_channel' tinyagentos/routes tinyagentos/projects -g '!**/__pycache__/**'

Repository: jaylfc/taOS

Length of output: 7827


Canvas scopes should join the project membership sync
canvas_read/canvas_write are project-bound now, but this block still only adds project_tasks agents to project_members and the A2A channel. Canvas-only approvals won’t appear in Members, and canvas_write agents can’t get can_edit_canvas without a membership row. Include _CANVAS_SCOPES here, or split read vs write handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/agent_auth_requests.py` around lines 307 - 324, Update the
project membership and A2A synchronization logic for project-bound grants to
include agents approved with canvas_read or canvas_write, not only
project_tasks. Reuse _CANVAS_SCOPES and preserve the existing project_id
binding; ensure canvas_write approvals receive the membership data needed for
can_edit_canvas.


registry = _get_registry_store(request)
Expand Down
1 change: 1 addition & 0 deletions tinyagentos/routes/agent_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class OrgUpdateRequest(BaseModel):
"files_read", "files_write",
"tools_execute", "registry_feeds_read",
"project_tasks",
"canvas_read", "canvas_write",
})


Expand Down
Loading