From fced3a12937a3698d920dae042e5b209ff2a34fa Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Wed, 21 Oct 2026 18:03:51 +0200 Subject: [PATCH 1/3] fix(agent): refuse a single-record action given several records [PRD-1704] A single-record action triggered on several ids ran on the first record only and answered success, since ActionContextSingle.get_record reads the first record. An API client, an MCP call or an approval request could send such a selection, and every record past the first was silently skipped. The action resource now answers 422 for a single-record action whose selection names more than one id or is a select-all, before the permission check: no approval is asked for, approving a stored request runs nothing, and the form hooks refuse it too. Same fix as the Node agent (ForestAdmin/agent-nodejs#2028). Refs: PRD-1704 Co-Authored-By: Claude Opus 5.5 --- .../resources/actions/resources.py | 10 ++- .../tests/resources/actions/test_resources.py | 89 ++++++++++++++++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py index 36a9cf5e3..e1b3306c2 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py @@ -16,7 +16,7 @@ from forestadmin.agent_toolkit.utils.forest_schema.type import ForestServerActionField from forestadmin.agent_toolkit.utils.id import unpack_id from forestadmin.datasource_toolkit.decorators.action.result_builder import ResultBuilder -from forestadmin.datasource_toolkit.exceptions import BusinessError +from forestadmin.datasource_toolkit.exceptions import BusinessError, UnprocessableError from forestadmin.datasource_toolkit.interfaces.actions import ActionsScope from forestadmin.datasource_toolkit.interfaces.query.condition_tree.factory import ConditionTreeFactory from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.base import ConditionTree @@ -164,8 +164,14 @@ async def _get_records_selection(self, request: ActionRequest) -> Filter: filter_ = build_filter(request, condition_tree) # Restrict the filter to the selected records for single or bulk actions. - if request.collection.schema["actions"][request.action_name].scope != ActionsScope.GLOBAL: + action_scope = request.collection.schema["actions"][request.action_name].scope + if action_scope != ActionsScope.GLOBAL: selection_ids, exclude_ids = parse_selection_ids(request.collection.schema, request) + + # A single action only ever runs on the first record: refuse rather than skip the others. + if action_scope == ActionsScope.SINGLE and (exclude_ids or len(selection_ids) > 1): + raise UnprocessableError("This action can only be triggered on one record at a time.") + selected_ids = ConditionTreeFactory.match_ids(request.collection.schema, selection_ids) if exclude_ids: selected_ids = selected_ids.inverse() diff --git a/src/agent_toolkit/tests/resources/actions/test_resources.py b/src/agent_toolkit/tests/resources/actions/test_resources.py index 10d1f3b15..8d5da33e3 100644 --- a/src/agent_toolkit/tests/resources/actions/test_resources.py +++ b/src/agent_toolkit/tests/resources/actions/test_resources.py @@ -28,7 +28,7 @@ from forestadmin.datasource_toolkit.decorators.action.result_builder import ResultBuilder from forestadmin.datasource_toolkit.decorators.action.types.actions import ActionDict from forestadmin.datasource_toolkit.decorators.datasource_decorator import DatasourceDecorator -from forestadmin.datasource_toolkit.exceptions import ForbiddenError +from forestadmin.datasource_toolkit.exceptions import ForbiddenError, UnprocessableError from forestadmin.datasource_toolkit.interfaces.actions import ActionFieldType, ActionsScope from forestadmin.datasource_toolkit.interfaces.fields import FieldType, Operator, PrimitiveType from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.leaf import ConditionTreeLeaf @@ -1109,3 +1109,90 @@ async def execute_with_filter_capture(ctx, rb): # Should contain the selected IDs (1 and 2) self.assertIn("1", filter_str) self.assertIn("2", filter_str) + + +class TestSingleActionGivenSeveralRecords(BaseTestActionResource): + """A single action context only ever reads the first record, so a selection naming more than one + would run on that record alone and still answer success.""" + + message = "This action can only be triggered on one record at a time." + + def setUp(self) -> None: + super().setUp() + self.permission_service.can_smart_action.reset_mock() + self.decorated_collection_book.add_action( + "test_action_single", {"scope": ActionsScope.SINGLE, "execute": lambda ctx, rb: rb.success()} + ) + + def _request(self, **attributes): + body = {"data": {"attributes": {"values": {}, "collection_name": "Book", **attributes}}} + return ActionRequest( + method=RequestMethod.POST, + action_name="test_action_single", + collection=self.decorated_collection_book, + body=body, + query={ + "timezone": "Europe/Paris", + "collection_name": "Book", + "action_name": 0, + "slug": "test_action_single", + }, + headers={}, + user=self.mocked_caller, + client_ip="127.0.0.1", + ) + + def _execute(self, request): + with patch.object( + self.decorated_collection_book, "execute", new_callable=AsyncMock, return_value=ResultBuilder().success() + ) as mocked_execute: + with self.assertRaises(UnprocessableError) as raised: + self.loop.run_until_complete(self.action_resource.execute(request)) + return mocked_execute, raised.exception + + def test_execute_should_refuse_two_ids_and_run_nothing(self): + mocked_execute, error = self._execute(self._request(ids=["1", "2"])) + + self.assertIn(self.message, str(error)) + mocked_execute.assert_not_awaited() + + def test_execute_should_refuse_two_ids_before_checking_the_permissions(self): + self._execute(self._request(ids=["1", "2"])) + + self.permission_service.can_smart_action.assert_not_awaited() + + def test_execute_should_refuse_the_approval_of_a_request_naming_two_ids(self): + signed = jwt.encode( + {"data": {"attributes": {"values": {}, "collection_name": "Book", "ids": ["1", "2"]}}}, + self.options["env_secret"], + ) + mocked_execute, error = self._execute(self._request(ids=["1", "2"], signed_approval_request=signed)) + + self.assertIn(self.message, str(error)) + mocked_execute.assert_not_awaited() + + def test_execute_should_refuse_a_select_all_selection(self): + mocked_execute, error = self._execute(self._request(ids=[], all_records=True, all_records_ids_excluded=[])) + + self.assertIn(self.message, str(error)) + mocked_execute.assert_not_awaited() + + def test_hook_should_refuse_two_ids_and_compute_no_form(self): + with patch.object( + self.decorated_collection_book, "get_form", new_callable=AsyncMock, return_value=[] + ) as mocked_get_form: + with self.assertRaises(UnprocessableError) as raised: + self.loop.run_until_complete(self.action_resource.hook(self._request(ids=["1", "2"]))) + + self.assertIn(self.message, str(raised.exception)) + mocked_get_form.assert_not_awaited() + + def test_execute_should_still_run_on_exactly_one_id(self): + with patch.object( + self.decorated_collection_book, "execute", new_callable=AsyncMock, return_value=ResultBuilder().success() + ) as mocked_execute: + response = self.loop.run_until_complete(self.action_resource.execute(self._request(ids=["1"]))) + + self.assertEqual(response.status, 200) + mocked_execute.assert_awaited_once_with(self.mocked_caller, "test_action_single", {}, ANY) + self.assertEqual(mocked_execute.await_args.args[3].condition_tree, ConditionTreeLeaf("id", Operator.EQUAL, 1)) From b3941932d00ad8151d27ed7bdc5498229ea5a4c1 Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Wed, 21 Oct 2026 18:35:00 +0200 Subject: [PATCH 2/3] fix(agent): refuse a single-record action given no record id [PRD-1704] An empty selection leaves the action's filter unrestricted, so a single-record action sent `ids: []` ran on whichever record it read first. The single-record guard now refuses anything but exactly one id. Refs: PRD-1704 Co-Authored-By: Claude Opus 5.5 --- .../agent_toolkit/resources/actions/resources.py | 5 +++-- src/agent_toolkit/tests/resources/actions/test_resources.py | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py index e1b3306c2..8a97c8c81 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py @@ -168,8 +168,9 @@ async def _get_records_selection(self, request: ActionRequest) -> Filter: if action_scope != ActionsScope.GLOBAL: selection_ids, exclude_ids = parse_selection_ids(request.collection.schema, request) - # A single action only ever runs on the first record: refuse rather than skip the others. - if action_scope == ActionsScope.SINGLE and (exclude_ids or len(selection_ids) > 1): + # A single action runs on the first record it reads, and an empty selection below restricts nothing: + # refuse anything but exactly one id rather than run on a record nobody chose. + if action_scope == ActionsScope.SINGLE and (exclude_ids or len(selection_ids) != 1): raise UnprocessableError("This action can only be triggered on one record at a time.") selected_ids = ConditionTreeFactory.match_ids(request.collection.schema, selection_ids) diff --git a/src/agent_toolkit/tests/resources/actions/test_resources.py b/src/agent_toolkit/tests/resources/actions/test_resources.py index 8d5da33e3..0ec2f1884 100644 --- a/src/agent_toolkit/tests/resources/actions/test_resources.py +++ b/src/agent_toolkit/tests/resources/actions/test_resources.py @@ -1177,6 +1177,12 @@ def test_execute_should_refuse_a_select_all_selection(self): self.assertIn(self.message, str(error)) mocked_execute.assert_not_awaited() + def test_execute_should_refuse_an_empty_selection_and_run_nothing(self): + mocked_execute, error = self._execute(self._request(ids=[])) + + self.assertIn(self.message, str(error)) + mocked_execute.assert_not_awaited() + def test_hook_should_refuse_two_ids_and_compute_no_form(self): with patch.object( self.decorated_collection_book, "get_form", new_callable=AsyncMock, return_value=[] From 1bdfba65b9a314e41ce38597890f5c5f0bd06c91 Mon Sep 17 00:00:00 2001 From: Brian Fox Date: Wed, 21 Oct 2026 18:35:24 +0200 Subject: [PATCH 3/3] refactor(agent): shorten the single-record guard comment [PRD-1704] Refs: PRD-1704 Co-Authored-By: Claude Opus 5.5 --- .../forestadmin/agent_toolkit/resources/actions/resources.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py index 8a97c8c81..6f61e2941 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/actions/resources.py @@ -168,8 +168,7 @@ async def _get_records_selection(self, request: ActionRequest) -> Filter: if action_scope != ActionsScope.GLOBAL: selection_ids, exclude_ids = parse_selection_ids(request.collection.schema, request) - # A single action runs on the first record it reads, and an empty selection below restricts nothing: - # refuse anything but exactly one id rather than run on a record nobody chose. + # A single action runs on the first record it reads: refuse anything but exactly one id. if action_scope == ActionsScope.SINGLE and (exclude_ids or len(selection_ids) != 1): raise UnprocessableError("This action can only be triggered on one record at a time.")