Skip to content

Commit e62a2a7

Browse files
authored
fix(tasks): omit params from the list-tasks response (#377)
## Summary `GET /tasks` (list) returned the full task object, the same `TaskResponse` the single-task GET returns, so every item included `params`: the arbitrary, caller-supplied create-time payload. Callers routinely stash sensitive material there (credentials, tokens, PII). Because the list is broadly scoped, that turned a single list call into a bulk read of every task's `params`. This makes the list a lean summary (`TaskSummary`, no `params`) and keeps the full record on the single-task fetch. A list response should carry only what a collection view needs; the full record belongs on the item fetch. **Scope:** the list response only. Behavior of `GET /tasks/{id}`, `GET /tasks/name/{name}`, `POST`/`PATCH`, and the domain/repository layers is unchanged. ## Endpoint contract (after) ```mermaid flowchart TD Client(["Client"]) Client -->|"GET /tasks (list)"| Summary["list of TaskSummary<br/>id, name, status, status_reason,<br/>created_at, updated_at, cleaned_at,<br/>task_metadata, agents"] Client -->|"GET /tasks/{id} (detail)"| Full["TaskResponse<br/>(all summary fields) + params"] Summary -. "need params for one task?" .-> Client Client ==>|"fetch that id"| Full classDef lean fill:#e8f5e9,stroke:#43a047,color:#1b5e20; classDef full fill:#fdecea,stroke:#e53935,color:#b71c1c; class Summary lean; class Full full; ``` - **List** returns the lean summary for every task, cheap, and safe to return in bulk. - **Detail** still returns everything, including `params`, and is fetched one id at a time (and is subject to the same per-request authorization as before). ## Schema `TaskSummary` is a deliberate allowlist. It is a standalone model (not a subclass of `Task`) so `params` cannot leak in via inheritance, and because `BaseModel` ignores extra fields, validating a `TaskEntity` (which still carries `params`) into a `TaskSummary` drops `params` at serialization. ```mermaid classDiagram class TaskSummary { +id +name +status +status_reason +created_at +updated_at +cleaned_at +task_metadata +agents } class TaskResponse { +id +name +status +status_reason +created_at +updated_at +cleaned_at +task_metadata +agents +params } note for TaskSummary "list response, omits params" note for TaskResponse "single-task detail, includes params (may carry credentials or PII)" ``` The two share the same safe fields; only `TaskResponse` exposes `params`. ## Before / after ```jsonc // BEFORE - one item in the list response { "id": "0c1f8e2a-…", "name": "…", "status": "RUNNING", "created_at": "…", "updated_at": "…", "cleaned_at": null, "task_metadata": { "display_name": "…" }, "params": { "auth_token": "…", // whatever the caller stored at create time, "user_email": "…", // e.g. credentials and PII, returned in bulk "…": "…" } } // AFTER - same item, lean summary (no params) { "id": "0c1f8e2a-…", "name": "…", "status": "RUNNING", "created_at": "…", "updated_at": "…", "cleaned_at": null, "task_metadata": { "display_name": "…" }, "agents": null } ``` What changes across a whole list call: ```mermaid flowchart LR subgraph Before["Before: GET /tasks"] direction TB b["N tasks × full object<br/>N × params (creds/PII)"] end subgraph After["After: GET /tasks"] direction TB a["N tasks × lean summary<br/>0 × params"] end Before --> After classDef bad fill:#fdecea,stroke:#e53935,color:#b71c1c; classDef good fill:#e8f5e9,stroke:#43a047,color:#1b5e20; class Before bad; class After good; ``` ## Field reference | Field | In list (`TaskSummary`) | In detail (`TaskResponse`) | Notes | |---|---|---|---| | `id` | yes | yes | System-assigned task id | | `name` | yes | yes | Optional, caller-supplied | | `status` / `status_reason` | yes | yes | Lifecycle state | | `created_at` / `updated_at` / `cleaned_at` | yes | yes | Timestamps | | `task_metadata` | yes | yes | Display / routing / filter metadata (the list can filter on it) | | `agents` | yes | yes | Populated only with `?relationships=agents` | | **`params`** | **no** | yes | Arbitrary create-time payload; may carry credentials/PII | ## Breaking change and migration Removing `params` from the list is an intentional, security-motivated breaking change for typed consumers that read `params` off a **list** item. ```mermaid flowchart TD Q{"Reading params off a list item?"} Q -->|No| OK["No change needed"] Q -->|Yes| Fix["Fetch the task by id, then read params"] classDef ok fill:#e8f5e9,stroke:#43a047,color:#1b5e20; class OK ok; ``` The SDKs are generated from `agentex/openapi.yaml` (Stainless), so the list-item type regenerates automatically on merge; consumers pick up the change when they bump the SDK. This PR regenerates and commits `openapi.yaml` (the `list_tasks` 200 now references `TaskSummary`), which the `openapi-spec` CI check enforces. ## Consumers touched in this PR - **Developer UI** (`agentex-ui`): `createTaskName` fell back to `params.description` for the task label; switched to the task `name` (`task_metadata.display_name → name → "Unnamed task"`). No other code reads `params` off a listed task. ## Implementation - `src/api/schemas/tasks.py`: add `TaskSummary` (allowlist, omits `params`). - `src/api/routes/tasks.py`: `list_tasks` `response_model=list[TaskSummary]`, returns `TaskSummary.model_validate(...)` per entity. Single-task routes unchanged. - `agentex-ui/lib/task-utils.ts` + test: name-derivation fallback. - `agentex/openapi.yaml`: regenerated. ## Follow-ups / known limitations - **`task_metadata` is still free-form (`dict[str, Any]`)** and partly caller-influenced. By convention it holds non-secret display/routing/filter metadata (and the list can filter on it), so it stays in the summary. If we want the list to be strict rather than convention-safe, a follow-up could give `task_metadata` a typed shape so callers cannot stash sensitive data that the list would surface. - **Concurrency is out of scope**: unrelated to this change; not touched here. ## Test plan ```mermaid flowchart LR subgraph list["GET /tasks"] L1["task present in list"] L2["params NOT in item"] end subgraph get["GET /tasks/{id}"] G1["params present + correct"] end list --> get ``` - Integration (`tests/integration/api/tasks/test_tasks_api.py`): list omits `params` for both populated- and null-`params` tasks; the schema test asserts `params` absent on list items; single-task GET (by id and by name) still returns `params`. - UI unit (`agentex-ui/lib/task-utils.test.ts`): task-name derivation from `display_name` / `name`. - `openapi.yaml` regenerated and committed. - Verified directly that `TaskSummary.model_validate({... "params": {...}})` drops `params` while `TaskResponse` keeps it; `ruff` clean. <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR fixes an information-disclosure issue where `GET /tasks` (list) returned the full `TaskResponse` — including `params`, an arbitrary caller-supplied payload that can hold credentials and PII — for every task in a single call. The fix introduces a standalone `TaskSummary` allowlist model that omits `params`, updates the list route's `response_model`, regenerates the OpenAPI spec, and adapts the UI's task-name derivation to use `task.name` instead of `params.description`. - **`TaskSummary`** is a standalone `BaseModel` (not a subclass of `Task`) so `params` cannot leak via inheritance; Pydantic v2's default `extra='ignore'` ensures `model_validate` silently drops the field. - **Single-task routes** (`GET /tasks/{id}`, `GET /tasks/name/{name}`, lifecycle endpoints) are unchanged and continue to return `params`. - **UI** now derives the task label from `task_metadata.display_name → task.name → 'Unnamed task'` instead of the now-absent `params.description`. <details><summary><h3>Confidence Score: 5/5</h3></summary> Safe to merge — the change is a targeted, well-scoped fix that strips a sensitive field from a bulk endpoint without touching any single-task or write paths. The allowlist model is correctly designed as a standalone BaseModel (no Task inheritance), and Pydantic v2's default extra-field behaviour ensures params is silently dropped at serialization. The custom BaseModel in model_utils.py does not set extra='allow', so there is no bypass path. Single-task routes, lifecycle endpoints, and the domain layer are untouched. Integration tests verify the before/after contract for both populated- and null-params tasks, and the UI fallback is covered by unit tests. **Files Needing Attention:** No files require special attention. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex/src/api/schemas/tasks.py | Adds TaskSummary as a standalone BaseModel allowlist that omits params; correct approach — no inheritance from Task prevents accidental field leakage. | | agentex/src/api/routes/tasks.py | list_tasks response_model changed to list[TaskSummary] and serialization updated; single-task and lifecycle routes are correctly unchanged. | | agentex-ui/lib/task-utils.ts | createTaskName fallback updated from params.description to task.name; correct adaptation to the new TaskSummary shape. | | agentex-ui/lib/task-utils.test.ts | Tests updated to cover the new name-derivation logic (display_name, prefix stripping, task.name fallback, Unnamed task); coverage looks complete. | | agentex/tests/integration/api/tasks/test_tasks_api.py | Integration tests flipped to assert params absent from list items and still present on single-task GET; covers both populated and null-params tasks. | | agentex/openapi.yaml | list_tasks 200 response now references TaskSummary; TaskSummary schema added with correct allowlist fields matching the Python model. | </details> <sub>Reviews (2): Last reviewed commit: ["Merge branch &#39;main&#39; into fix/list-tasks-..."](6977d67) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=47039695)</sub> <!-- /greptile_comment -->
1 parent b5a1d17 commit e62a2a7

6 files changed

Lines changed: 150 additions & 71 deletions

File tree

agentex-ui/lib/task-utils.test.ts

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,69 +5,49 @@ import { createTaskName } from '@/lib/task-utils';
55
import type { TaskListResponse } from 'agentex/resources';
66

77
describe('createTaskName', () => {
8-
it('returns description when params.description is a string', () => {
8+
it('returns task_metadata.display_name when present', () => {
99
const task = {
1010
id: '123',
11-
params: {
12-
description: 'Test task description',
13-
},
14-
} as TaskListResponse.TaskListResponseItem;
15-
16-
expect(createTaskName(task)).toBe('Test task description');
17-
});
18-
19-
it('returns "Unnamed task" when params.description is missing', () => {
20-
const task = {
21-
params: {},
22-
} as TaskListResponse.TaskListResponseItem;
23-
24-
expect(createTaskName(task)).toBe('Unnamed task');
25-
});
26-
27-
it('returns "Unnamed task" when params is missing', () => {
28-
const task = {} as TaskListResponse.TaskListResponseItem;
11+
task_metadata: { display_name: 'My task' },
12+
} as unknown as TaskListResponse.TaskListResponseItem;
2913

30-
expect(createTaskName(task)).toBe('Unnamed task');
14+
expect(createTaskName(task)).toBe('My task');
3115
});
3216

33-
it('returns "Unnamed task" when params.description is not a string', () => {
17+
it('strips the legacy scheduled-message prefix from display_name', () => {
3418
const task = {
35-
params: {
36-
description: 123,
19+
id: '123',
20+
task_metadata: {
21+
display_name: 'Scheduled Message: Daily digest',
22+
schedule_id: 'sched-1',
3723
},
3824
} as unknown as TaskListResponse.TaskListResponseItem;
3925

40-
expect(createTaskName(task)).toBe('Unnamed task');
26+
expect(createTaskName(task)).toBe('Daily digest');
4127
});
4228

43-
it('returns "Unnamed task" when params.description is null', () => {
29+
it('falls back to the task name when there is no display_name', () => {
4430
const task = {
45-
params: {
46-
description: null,
47-
},
31+
id: '123',
32+
name: 'my-task-name',
4833
} as unknown as TaskListResponse.TaskListResponseItem;
4934

50-
expect(createTaskName(task)).toBe('Unnamed task');
35+
expect(createTaskName(task)).toBe('my-task-name');
5136
});
5237

53-
it('returns "Unnamed task" when params.description is undefined', () => {
38+
it('returns "Unnamed task" when neither display_name nor name is set', () => {
5439
const task = {
5540
id: '123',
56-
params: {
57-
description: undefined,
58-
},
59-
} as TaskListResponse.TaskListResponseItem;
41+
} as unknown as TaskListResponse.TaskListResponseItem;
6042

6143
expect(createTaskName(task)).toBe('Unnamed task');
6244
});
6345

64-
it('returns "Unnamed task" for empty string description', () => {
46+
it('returns "Unnamed task" when name is an empty string', () => {
6547
const task = {
6648
id: '123',
67-
params: {
68-
description: '',
69-
},
70-
} as TaskListResponse.TaskListResponseItem;
49+
name: '',
50+
} as unknown as TaskListResponse.TaskListResponseItem;
7151

7252
expect(createTaskName(task)).toBe('Unnamed task');
7353
});

agentex-ui/lib/task-utils.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,8 @@ export function createTaskName(
2020
return displayName;
2121
}
2222

23-
if (
24-
task?.params?.description &&
25-
typeof task.params.description === 'string'
26-
) {
27-
return task.params.description;
23+
if (typeof task?.name === 'string' && task.name) {
24+
return task.name;
2825
}
2926

3027
return 'Unnamed task';

agentex/openapi.yaml

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,8 @@ paths:
555555
tags:
556556
- Tasks
557557
summary: List Tasks
558-
description: List all tasks.
558+
description: List tasks. Returns a lean summary per task and omits `params`;
559+
fetch GET /tasks/{task_id} for the full record including `params`.
559560
operationId: list_tasks_tasks_get
560561
parameters:
561562
- name: agent_id
@@ -641,7 +642,7 @@ paths:
641642
schema:
642643
type: array
643644
items:
644-
$ref: '#/components/schemas/TaskResponse'
645+
$ref: '#/components/schemas/TaskSummary'
645646
title: Response List Tasks Tasks Get
646647
'422':
647648
description: Validation Error
@@ -6845,6 +6846,68 @@ components:
68456846
title: Optional reason for the status change
68466847
type: object
68476848
title: TaskStatusReasonRequest
6849+
TaskSummary:
6850+
properties:
6851+
id:
6852+
type: string
6853+
title: Unique Task ID
6854+
name:
6855+
anyOf:
6856+
- type: string
6857+
- type: 'null'
6858+
title: Unique name of the task
6859+
status:
6860+
anyOf:
6861+
- $ref: '#/components/schemas/TaskStatus'
6862+
- type: 'null'
6863+
title: The current status of the task
6864+
status_reason:
6865+
anyOf:
6866+
- type: string
6867+
- type: 'null'
6868+
title: The reason for the current task status
6869+
created_at:
6870+
anyOf:
6871+
- type: string
6872+
format: date-time
6873+
- type: 'null'
6874+
title: The timestamp when the task was created
6875+
updated_at:
6876+
anyOf:
6877+
- type: string
6878+
format: date-time
6879+
- type: 'null'
6880+
title: The timestamp when the task was last updated
6881+
cleaned_at:
6882+
anyOf:
6883+
- type: string
6884+
format: date-time
6885+
- type: 'null'
6886+
title: The timestamp when the task's content was cleaned for retention compliance;
6887+
null when active
6888+
task_metadata:
6889+
anyOf:
6890+
- additionalProperties: true
6891+
type: object
6892+
- type: 'null'
6893+
title: Task metadata
6894+
agents:
6895+
anyOf:
6896+
- items:
6897+
$ref: '#/components/schemas/Agent'
6898+
type: array
6899+
- type: 'null'
6900+
title: Agents associated with this task (only populated when 'agents' view
6901+
is requested)
6902+
type: object
6903+
required:
6904+
- id
6905+
title: TaskSummary
6906+
description: 'Lean list-response shape. Omits `params` (the arbitrary create-time
6907+
6908+
payload, which can carry per-caller secrets and PII); fetch GET /tasks/{id}
6909+
6910+
for the full record.'
68486911
TextContent:
68496912
properties:
68506913
type:

agentex/src/api/routes/tasks.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
TaskResponse,
1818
TaskStatus,
1919
TaskStatusReasonRequest,
20+
TaskSummary,
2021
UpdateTaskRequest,
2122
)
2223
from src.domain.entities.tasks import TaskStatus as DomainTaskStatus
@@ -73,9 +74,12 @@ async def get_task_by_name(
7374

7475
@router.get(
7576
"",
76-
response_model=list[TaskResponse],
77+
response_model=list[TaskSummary],
7778
summary="List Tasks",
78-
description="List all tasks.",
79+
description=(
80+
"List tasks. Returns a lean summary per task and omits `params`; "
81+
"fetch GET /tasks/{task_id} for the full record including `params`."
82+
),
7983
)
8084
async def list_tasks(
8185
task_use_case: DTaskUseCase,
@@ -143,7 +147,7 @@ async def list_tasks(
143147
order_direction=order_direction,
144148
relationships=relationships,
145149
)
146-
return [TaskResponse.model_validate(task_entity) for task_entity in task_entities]
150+
return [TaskSummary.model_validate(task_entity) for task_entity in task_entities]
147151

148152

149153
@router.delete(

agentex/src/api/schemas/tasks.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,49 @@ class TaskResponse(Task):
7474
)
7575

7676

77+
class TaskSummary(BaseModel):
78+
"""Lean list-response shape. Omits `params` (the arbitrary create-time
79+
payload, which can carry per-caller secrets and PII); fetch GET /tasks/{id}
80+
for the full record."""
81+
82+
id: str = Field(
83+
...,
84+
title="Unique Task ID",
85+
)
86+
name: str | None = Field(
87+
None,
88+
title="Unique name of the task",
89+
)
90+
status: TaskStatus | None = Field(
91+
None,
92+
title="The current status of the task",
93+
)
94+
status_reason: str | None = Field(
95+
None,
96+
title="The reason for the current task status",
97+
)
98+
created_at: datetime | None = Field(
99+
None,
100+
title="The timestamp when the task was created",
101+
)
102+
updated_at: datetime | None = Field(
103+
None,
104+
title="The timestamp when the task was last updated",
105+
)
106+
cleaned_at: datetime | None = Field(
107+
None,
108+
title="The timestamp when the task's content was cleaned for retention compliance; null when active",
109+
)
110+
task_metadata: dict[str, Any] | None = Field(
111+
None,
112+
title="Task metadata",
113+
)
114+
agents: list["Agent"] | None = Field(
115+
default=None,
116+
title="Agents associated with this task (only populated when 'agents' view is requested)",
117+
)
118+
119+
77120
class UpdateTaskRequest(BaseModel):
78121
task_metadata: dict[str, Any] | None = Field(
79122
None,

agentex/tests/integration/api/tasks/test_tasks_api.py

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ async def test_list_tasks_returns_valid_structure_and_schema(
141141
for task in tasks:
142142
assert "id" in task and isinstance(task["id"], str)
143143

144+
# The list is a lean summary and must never carry `params` (the
145+
# arbitrary create-time payload that can hold secrets/PII).
146+
assert "params" not in task
147+
144148
# Check if this is our test task
145149
if task["id"] == test_task.id:
146150
found_test_task = True
@@ -499,32 +503,24 @@ async def test_get_task_by_name_non_existent_returns_404(self, isolated_client):
499503
assert response.status_code == 404
500504

501505
#
502-
async def test_list_tasks_includes_params_in_response(
506+
async def test_list_tasks_omits_params_in_response(
503507
self, isolated_client, test_task_with_params
504508
):
505-
"""Test that list tasks endpoint includes params field in response"""
509+
"""The list summary must omit `params` even when the task has them
510+
(they can carry secrets/PII); fetch a single task for the full record."""
506511
# When - Request all tasks
507512
response = await isolated_client.get("/tasks")
508513

509-
# Then - Should succeed and include params in response
514+
# Then - The task is present but its params are not serialized
510515
assert response.status_code == 200
511516
tasks = response.json()
512517
assert isinstance(tasks, list)
513518

514-
# Find our test task with params
515519
params_task = next(
516520
(task for task in tasks if task["id"] == test_task_with_params.id), None
517521
)
518-
assert params_task is not None, "Task with params should be in the list"
519-
520-
# Verify params field exists and has correct structure
521-
assert "params" in params_task
522-
assert params_task["params"] is not None
523-
assert params_task["params"]["model"] == "gpt-4"
524-
assert params_task["params"]["temperature"] == 0.8
525-
assert params_task["params"]["max_tokens"] == 2000
526-
assert params_task["params"]["nested"]["setting"] == "value"
527-
assert params_task["params"]["nested"]["numbers"] == [1, 2, 3]
522+
assert params_task is not None, "Task should be in the list"
523+
assert "params" not in params_task
528524

529525
#
530526
async def test_get_task_by_id_includes_params_in_response(
@@ -579,26 +575,22 @@ async def test_get_task_by_name_includes_params_in_response(
579575
assert task_data["params"]["nested"]["numbers"] == [1, 2, 3]
580576

581577
#
582-
async def test_list_tasks_handles_null_params_correctly(
578+
async def test_list_tasks_omits_params_for_null_params_task(
583579
self, isolated_client, test_task
584580
):
585-
"""Test that list tasks handles tasks with null params correctly"""
581+
"""A task created without params also has no `params` key in the list."""
586582
# When - Request all tasks (test_task has null params)
587583
response = await isolated_client.get("/tasks")
588584

589-
# Then - Should succeed and handle null params
585+
# Then - The task is present with no params field
590586
assert response.status_code == 200
591587
tasks = response.json()
592588

593-
# Find our test task without params
594589
null_params_task = next(
595590
(task for task in tasks if task["id"] == test_task.id), None
596591
)
597592
assert null_params_task is not None
598-
599-
# Verify params field exists and is null
600-
assert "params" in null_params_task
601-
assert null_params_task["params"] is None
593+
assert "params" not in null_params_task
602594

603595
#
604596
async def test_get_task_by_id_handles_null_params_correctly(

0 commit comments

Comments
 (0)