fix(tasks): audit pass — permission checks, tool gaps, hard reload

- PUT/PATCH/DELETE /api/tasks/:id now use get_note_for_user + can_write_note
  so shared project editors can mutate tasks; owners unaffected
- PATCH /api/tasks/:id/status gets same treatment
- All write routes call update_note/delete_note with note.user_id (owner)
  not the accessing user's uid, matching the milestone fix pattern
- create_task tool gains tags (array) and status (enum) parameters;
  handler now passes tags to create_note and respects initial status
- create_task tool response now includes milestone_id and parent_id
- update_note tool gains milestone parameter; handler resolves the
  milestone by title within the note's current (or newly set) project,
  clears milestone_id when project is cleared
- list_tasks tool gains q keyword search parameter; passed through
  to list_notes
- TaskEditorView: replace window.location.reload() with
  router.push('/tasks/:id') after save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:48:07 -04:00
parent c5191837fb
commit d9bd16633f
3 changed files with 69 additions and 7 deletions
+43 -1
View File
@@ -80,6 +80,11 @@ _CORE_TOOLS = [
"type": "string",
"description": "Optional task description or details",
},
"status": {
"type": "string",
"enum": ["todo", "in_progress", "done", "cancelled"],
"description": "Initial task status (default: todo)",
},
"due_date": {
"type": "string",
"description": "Optional due date in YYYY-MM-DD format",
@@ -89,6 +94,11 @@ _CORE_TOOLS = [
"enum": ["none", "low", "medium", "high"],
"description": "Task priority level",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags for the task (without # prefix)",
},
"project": {
"type": "string",
"description": "Optional project name to assign this task to",
@@ -204,6 +214,10 @@ _CORE_TOOLS = [
"type": "string",
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
},
"milestone": {
"type": "string",
"description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)",
},
"recurrence_rule": {
"type": "object",
"description": "Set or update recurrence (same format as create_task). Pass null to remove.",
@@ -344,6 +358,10 @@ _CORE_TOOLS = [
"parameters": {
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Optional keyword filter — searches title and body",
},
"status": {
"type": "array",
"items": {
@@ -1110,11 +1128,16 @@ async def execute_tool(
best_score, best_note = sem_hits[0]
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A task with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."}
task_tags = arguments.get("tags", [])
if not isinstance(task_tags, list):
task_tags = []
task_status = arguments.get("status", "todo")
note = await create_note(
user_id=user_id,
title=task_title,
body=task_body,
status="todo",
tags=task_tags,
status=task_status,
priority=arguments.get("priority", "none"),
due_date=_parse_due_date(arguments.get("due_date")),
project_id=pre_fields.get("project_id"),
@@ -1139,6 +1162,8 @@ async def execute_tool(
"priority": note.priority,
"due_date": str(note.due_date) if note.due_date else None,
"project_id": note.project_id,
"milestone_id": note.milestone_id,
"parent_id": note.parent_id,
},
"suggested_tags": suggested,
}
@@ -1258,6 +1283,22 @@ async def execute_tool(
update_fields["project_id"] = proj.id
else:
update_fields["project_id"] = None
update_fields["milestone_id"] = None # clear milestone when project is cleared
if "milestone" in arguments:
milestone_name = arguments["milestone"]
if milestone_name:
# Resolve the project context for milestone lookup
ref_project_id = update_fields.get("project_id") or note.project_id
if ref_project_id is None:
return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."}
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd
ms = await _gmbt_upd(user_id, ref_project_id, milestone_name)
if ms is None:
return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."}
update_fields["milestone_id"] = ms.id
else:
update_fields["milestone_id"] = None
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
@@ -1303,6 +1344,7 @@ async def execute_tool(
milestone_id = ms.id
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or None,
is_task=True,
status=arguments.get("status"),
priority=arguments.get("priority"),