diff --git a/src/langbot/pkg/api/application/tool_grants.py b/src/langbot/pkg/api/application/tool_grants.py index a99cbf734..0fb7c2fed 100644 --- a/src/langbot/pkg/api/application/tool_grants.py +++ b/src/langbot/pkg/api/application/tool_grants.py @@ -10,6 +10,11 @@ from .contracts import ApplicationAPIError, ApplicationAPIException, Operation from .operation_docs import DOMAINS, get_operation_doc +def _is_processor(context: Any) -> bool: + principal = getattr(context, 'principal', None) + return getattr(principal, 'principal_type', None) == PrincipalType.PROCESSOR + + def validate_grant_change( ap: Any, editor_context: RequestContext, @@ -66,6 +71,18 @@ def validate_grant_change( if not added_ids: return normalized_new + # Granting is a human authorization act. A processor that can update processor + # configuration must not widen any processor's grants, including its own. + if _is_processor(editor_context): + raise ApplicationAPIException( + ApplicationAPIError( + code='permission_denied', + message=f'Processors cannot grant workspace operations: {added_ids}', + status_code=403, + details={'operations': added_ids, 'denied_operations': added_ids}, + ) + ) + app_api = getattr(ap, 'application_api', None) if app_api is None: from .api import ApplicationAPI @@ -147,7 +164,7 @@ def get_workspace_operations_catalog(ap: Any, context: Any) -> dict[str, Any]: has_perm = ( has_permission(context, spec.permission) if context is not None and hasattr(context, 'workspace') else False ) - grantable = bool(can_run and has_perm) + grantable = bool(can_run and has_perm and not _is_processor(context)) perm = spec.permission perm_str = perm.value if hasattr(perm, 'value') else str(perm) diff --git a/tests/unit_tests/api/test_tool_grants.py b/tests/unit_tests/api/test_tool_grants.py index deaadcab2..38436e519 100644 --- a/tests/unit_tests/api/test_tool_grants.py +++ b/tests/unit_tests/api/test_tool_grants.py @@ -249,3 +249,34 @@ def test_workspace_operations_catalog_and_grantable_flags(test_host): ] for op in viewer_view_ops: assert op['grantable'] is True + + +# 8. A processor must not widen grants, even within its own derived permissions +def test_processor_principal_cannot_add_grants(test_host): + import asyncio + + from langbot.pkg.api.application.processor_context import build_processor_request_context + + processor_ctx = asyncio.run( + build_processor_request_context( + test_host, + workspace_uuid='ws-1', + processor_kind='pipeline', + processor_uuid='pipe-1', + run_id='run-1', + operation_ids=['pipeline.extensions.update'], + ) + ) + old = ['pipeline.extensions.update'] + + with pytest.raises(ApplicationAPIException) as exc_info: + validate_grant_change(test_host, processor_ctx, old, [*old, 'bot.delete']) + assert exc_info.value.error.code == 'permission_denied' + assert exc_info.value.error.details['operations'] == ['bot.delete'] + + # Keeping or removing existing grants is still allowed. + assert validate_grant_change(test_host, processor_ctx, old, old) == old + assert validate_grant_change(test_host, processor_ctx, old, []) == [] + + catalog = get_workspace_operations_catalog(test_host, processor_ctx) + assert not any(op['grantable'] for op in catalog['workspace_operations'])