-
-
Notifications
You must be signed in to change notification settings - Fork 40
fix(tasks): add ownership guard to close_task #2287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1c7246c
ed53caf
25a206f
8146cac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| ### Fixed | ||
|
|
||
| - Closing a claimed task is refused unless you are the claim holder or the project lead; a non-claimer now gets 409 instead of silently closing someone else's card (#2287). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,15 +64,15 @@ async def _new_task(ctx, pid, title="T"): | |
| return resp.json()["id"] | ||
|
|
||
|
|
||
| async def _mint_agent(ctx, project_id, scopes=("project_tasks",)): | ||
| async def _mint_agent(ctx, project_id, scopes=("project_tasks",), handle="@grok"): | ||
| registry = ctx.app.state.agent_registry | ||
| grants = ctx.app.state.agent_grants | ||
| priv, _pub = ctx.app.state.agent_registry_keypair | ||
| rec = await registry.register( | ||
| framework="grok", | ||
| display_name="Grok", | ||
| origin="external-selfjoin", | ||
| handle="@grok", | ||
| handle=handle, | ||
| ) | ||
| cid = rec["canonical_id"] | ||
| await registry.set_status(cid, "active") | ||
|
|
@@ -530,6 +530,64 @@ async def test_admin_claims_and_closes_as_any_actor(self, ctx): | |
| assert close.status_code == 200 | ||
| assert close.json()["status"] == "closed" | ||
|
|
||
| async def test_admin_session_closes_agent_claimed_card_lead_is_agent(self, ctx): | ||
| """Issue #2191 repro: the board lead is an AGENT registry id, so a | ||
| session admin's actor id (a USER id) can never equal lead_member_id. | ||
| The ownership guard's force bypass must cover owner + session admin, | ||
| not just the lead, or every admin-session close of an agent-claimed | ||
| card returns 409.""" | ||
| pid = await _new_project(ctx, "alpha") | ||
| tid = await _new_task(ctx, pid) | ||
| lead_cid, _lead_token = await _mint_agent(ctx, pid, handle="@lead") | ||
| pstore = ctx.app.state.project_store | ||
| await pstore.add_member(pid, lead_cid, "native") | ||
| await pstore.set_lead(pid, lead_cid) | ||
| # A different lane agent claims the card. | ||
| lane_cid, lane_token = await _mint_agent(ctx, pid, handle="@lane") | ||
| async with _bare(ctx.app) as bare: | ||
| claim = await bare.post( | ||
| f"/api/projects/{pid}/tasks/{tid}/claim", | ||
| json={"claimer_id": lane_cid}, | ||
| headers=_hdr(lane_token), | ||
| ) | ||
| assert claim.status_code == 200, claim.text | ||
| assert claim.json()["claimed_by"] == lane_cid | ||
| # The admin session closes it, recording the closer as its own user id. | ||
| close = await ctx.client.post( | ||
| f"/api/projects/{pid}/tasks/{tid}/close", | ||
| json={"closed_by": ctx.uid}, | ||
| ) | ||
| assert close.status_code == 200, close.text | ||
| assert close.json()["status"] == "closed" | ||
| assert close.json()["closed_by"] == ctx.uid | ||
|
Comment on lines
+533
to
+562
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the project helper and session context setup.
rg -n -C 12 'async def _new_project|def _new_project|async def ctx|def ctx|class TestSessionRegression' \
tests/test_routes_projects_agent_tasks.py tests/conftest.pyRepository: jaylfc/taOS Length of output: 5829 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Trace project creation and ownership resolution, then inspect the full regression tests.
rg -n -C 15 'POST.*projects|create_project|user_id|is_admin|test_admin_session_closes_agent_claimed_card_lead_is_agent|_new_project' \
tests/test_routes_projects_agent_tasks.py tests/conftest.py tinyagentos . \
-g '*.py' | head -n 500Repository: jaylfc/taOS Length of output: 42909 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate the project route and inspect its POST handler plus the close authorization logic.
git ls-files '*.py' | rg 'project|route|task' | head -n 200
rg -n -C 20 '@.*post\(["'\'']/api/projects|router\.post|create.*project|project.*user_id|actor_id.*is_admin|is_admin.*actor_id|force' \
. -g '*.py' | head -n 500Repository: jaylfc/taOS Length of output: 7458 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Find the implementation files without assuming the package directory.
git ls-files | rg '(^|/)(projects?|tasks?|.*route.*)\.(py|js|ts)$' | head -n 300
rg -n -C 12 '"/api/projects|/api/projects|user_id.*project|project.*user_id|is_admin' \
--glob '*.py' --glob '*.ts' --glob '*.js' . | head -n 600Repository: jaylfc/taOS Length of output: 50367 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect only the project creation and task-close authorization implementations.
rg -n -C 18 'def .*project|`@router`\.(post|patch).*projects|create_project|def .*close|force|project\[.user_id.\]|is_admin' \
tinyagentos/routes/projects.py tinyagentos/routes/project_tasks.py 2>/dev/null | head -n 500Repository: jaylfc/taOS Length of output: 36111 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Read the close handler and the surrounding test to ensure the proposed
# isolation targets the exact authorization branch.
sed -n '800,930p' tinyagentos/routes/projects.py
sed -n '506,575p' tests/test_routes_projects_agent_tasks.pyRepository: jaylfc/taOS Length of output: 9378 Create the project under a different user before testing the session-admin bypass.
🤖 Prompt for AI Agents |
||
|
|
||
| async def test_lead_agent_closes_other_agents_card(self, ctx): | ||
| """Control (merge-gate path): the lead-agent bypass is preserved — a | ||
| lead agent still force-closes a card claimed by a different lane | ||
| agent.""" | ||
| pid = await _new_project(ctx, "alpha") | ||
| tid = await _new_task(ctx, pid) | ||
| lead_cid, lead_token = await _mint_agent(ctx, pid, handle="@lead") | ||
| pstore = ctx.app.state.project_store | ||
| await pstore.add_member(pid, lead_cid, "native") | ||
| await pstore.set_lead(pid, lead_cid) | ||
| lane_cid, lane_token = await _mint_agent(ctx, pid, handle="@lane") | ||
| async with _bare(ctx.app) as bare: | ||
| claim = await bare.post( | ||
| f"/api/projects/{pid}/tasks/{tid}/claim", | ||
| json={"claimer_id": lane_cid}, | ||
| headers=_hdr(lane_token), | ||
| ) | ||
| assert claim.status_code == 200, claim.text | ||
| close = await bare.post( | ||
| f"/api/projects/{pid}/tasks/{tid}/close", | ||
| json={"closed_by": lead_cid}, | ||
| headers=_hdr(lead_token), | ||
| ) | ||
| assert close.status_code == 200, close.text | ||
| assert close.json()["status"] == "closed" | ||
| assert close.json()["closed_by"] == lead_cid | ||
|
|
||
| async def test_unauthenticated_still_401(self, ctx): | ||
| pid = await _new_project(ctx, "alpha") | ||
| async with _bare(ctx.app) as bare: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -370,14 +370,25 @@ async def close_task( | |
| task_id: str, | ||
| closed_by: str, | ||
| reason: str | None = None, | ||
| *, | ||
| force: bool = False, | ||
| ) -> bool: | ||
| now = time.time() | ||
| cursor = await self._db.execute( | ||
| """UPDATE project_tasks | ||
| SET status = 'closed', closed_by = ?, closed_at = ?, close_reason = ?, updated_at = ? | ||
| WHERE id = ? AND status NOT IN ('closed', 'cancelled')""", | ||
| (closed_by, now, reason, now, task_id), | ||
| ) | ||
| if force: | ||
| cursor = await self._db.execute( | ||
| """UPDATE project_tasks | ||
| SET status = 'closed', closed_by = ?, closed_at = ?, close_reason = ?, updated_at = ? | ||
| WHERE id = ? AND status NOT IN ('closed', 'cancelled')""", | ||
| (closed_by, now, reason, now, task_id), | ||
| ) | ||
| else: | ||
| cursor = await self._db.execute( | ||
| """UPDATE project_tasks | ||
| SET status = 'closed', closed_by = ?, closed_at = ?, close_reason = ?, updated_at = ? | ||
| WHERE id = ? AND status NOT IN ('closed', 'cancelled') | ||
| AND (claimed_by IS NULL OR claimed_by = ?)""", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: New SQL ownership guard breaks existing callers The Reply with |
||
| (closed_by, now, reason, now, task_id, closed_by), | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| await self._db.commit() | ||
| changed = cursor.rowcount == 1 | ||
| if changed: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -994,8 +994,20 @@ async def close_task( | |
| existing = await store.get_task(task_id) | ||
| if existing is None or existing["project_id"] != project_id: | ||
| return JSONResponse({"error": "not found"}, status_code=404) | ||
| ok = await store.close_task(task_id, closed_by=closed_by, reason=payload.reason) | ||
| # Ownership-guard bypass: a card claimed by one agent may still be closed | ||
| # by the project lead (lead_member_id), the project owner (user_id), or a | ||
| # session admin. The lead is typically an AGENT registry id, so an | ||
| # admin/owner session caller (a USER id) can never equal it — widen the | ||
| # bypass to cover all three (issue #2191). | ||
| force = ( | ||
| project.get("lead_member_id") == actor_id | ||
| or project.get("user_id") == actor_id | ||
| or bool(getattr(request.state, "is_admin", False)) | ||
| ) | ||
| ok = await store.close_task(task_id, closed_by=closed_by, reason=payload.reason, force=force) | ||
| if not ok: | ||
| if existing.get("claimed_by") and existing["claimed_by"] != closed_by: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Stale
Reply with |
||
| return JSONResponse({"error": "not claimed by you"}, status_code=409) | ||
|
Comment on lines
1008
to
+1010
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Wrong 409 for cancelled When store.close_task() returns False, the route returns {"error": "not claimed by you"}
whenever existing.claimed_by != closed_by, even if the UPDATE was rejected because the task is
already closed/cancelled. This makes clients see an ownership error for
terminal-state/idempotency failures and can also misreport failures for force-capable callers.
Agent Prompt
coderabbitai[bot] marked this conversation as resolved.
|
||
| return JSONResponse({"error": "cannot close"}, status_code=409) | ||
| _beads_mark_dirty(request, project_id) | ||
| await pstore.log_activity(project_id, closed_by, "task.closed", {"task_id": task_id}) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION: Changelog is incomplete — implementation allows more than just the project lead
The changelog fragment says "the claim holder or the project lead", but the actual
forcebypass inroutes/projects.pyalso covers the project owner (user_id) and session admin (is_admin). Update the fragment to reflect all three bypass paths so the user-facing record matches the shipped behavior.Reply with
@kilocode-bot fix itto have Kilo Code address this issue.