Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/backend/integrations/internal_mcp/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,7 @@ class MethodDefinition:
# The argument remains hidden from the model and is injected only when the
# trusted entry point supplies it, but absence does not remove the method
# from the ordinary-turn capability projection.
ordinary_turn_optional_trusted_argument_bindings: (
Mapping[str, str] | None
) = None
ordinary_turn_optional_trusted_argument_bindings: Mapping[str, str] | None = None
# Map capability arguments to actor-authorised constrained choices resolved
# by the trusted entry point. The model may choose only from that bounded
# set; dispatch maps the stable selector to the current runtime value.
Expand Down Expand Up @@ -831,13 +829,21 @@ def _observe_validated_late_completion(
user_id, org_id = preexisting_actor_context
else:
user_id, org_id = payload_user_id, payload_org_id
# An installed local operator already bound both identity and
# provenance. Preserve that explicit authority across this gateway;
# ordinary authenticated actors and payload claims do not gain it.
actor_source = (
"preexisting_authenticated_or_workflow_context"
INTERNAL_MCP_TRUSTED_LOCAL_OPERATOR_SOURCE
if preexisting_actor_context is not None
and internal_mcp_actor_context_is_trusted_local_operator()
else (
INTERNAL_MCP_TRUSTED_LOCAL_OPERATOR_SOURCE
if self._trusted_actor_payload_fallback
else INTERNAL_MCP_UNTRUSTED_PAYLOAD_ACTOR_SOURCE
"preexisting_authenticated_or_workflow_context"
if preexisting_actor_context is not None
else (
INTERNAL_MCP_TRUSTED_LOCAL_OPERATOR_SOURCE
if self._trusted_actor_payload_fallback
else INTERNAL_MCP_UNTRUSTED_PAYLOAD_ACTOR_SOURCE
)
)
)
actor_source_token = _ACTOR_CONTEXT_SOURCE.set(actor_source)
Expand Down
91 changes: 88 additions & 3 deletions tests/backend/test_codex_von_operator_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,91 @@ def actor():

def test_conflicting_actor_is_rejected_before_canonical_call():
catalogue = SimpleNamespace(get=lambda name: None)
gateway = SimpleNamespace(invoke=lambda *a: pytest.fail("conflicting actor reached handler"))
bound = BoundTools({"actor_id": "#V#manager", "organisation_id": "#V#org", "methods": ["task_get"]}, catalogue, gateway)
assert bound.call("task_get", {"acting_user_concept_id": "#V#owner"}) == {"success": False, "error_code": "operator_actor_mismatch"}
gateway = SimpleNamespace(
invoke=lambda *a: pytest.fail("conflicting actor reached handler")
)
bound = BoundTools(
{
"actor_id": "#V#manager",
"organisation_id": "#V#org",
"methods": ["task_get"],
},
catalogue,
gateway,
)
assert bound.call("task_get", {"acting_user_concept_id": "#V#owner"}) == {
"success": False,
"error_code": "operator_actor_mismatch",
}


@pytest.mark.parametrize("operator", [True, False])
def test_real_gateway_preserves_installed_operator_task_authority(
monkeypatch, operator
):
from contextlib import nullcontext
from src.backend.integrations.internal_mcp.catalogue import (
_authorise_task_actor_mutation,
)
from src.backend.integrations.internal_mcp.gateway import (
InternalMCPGateway,
MethodCatalogue,
MethodDefinition,
INTERNAL_MCP_TRUSTED_LOCAL_OPERATOR_SOURCE,
bind_internal_mcp_actor_context_source,
)
from src.backend.integrations.internal_mcp.schemas import Schema
from src.backend.integrations.internal_mcp.transport import InternalMCPTransport
from src.backend.security.access_control import override_current_actor
from src.backend.services import task_management_service

task = {
"task_concept_id": "#V#task",
"organisation_concept_id": "#V#org",
"assignee_concept_id": "#V#other_agent",
"created_by_concept_id": "#V#owner",
}
monkeypatch.setattr(task_management_service, "get_task", lambda _: task)

def handler(**kwargs):
actor, found, error = _authorise_task_actor_mutation(
kwargs, surface="task_add_comment"
)
return error or {
"success": True,
"actor": actor.user_concept_id,
"task": found["task_concept_id"],
}

catalogue = MethodCatalogue()
catalogue.register(
MethodDefinition(
name="task_authority_probe",
handler=handler,
input_schema=Schema(required={}, optional={}, allow_unknown=True),
)
)
gateway = InternalMCPGateway(
catalogue=catalogue, transport=InternalMCPTransport(), enabled=True
)
provenance = (
bind_internal_mcp_actor_context_source(
INTERNAL_MCP_TRUSTED_LOCAL_OPERATOR_SOURCE,
preexisting_actor_context=("#V#manager", "#V#org"),
)
if operator
else nullcontext()
)
with override_current_actor("#V#manager", "#V#org"), provenance:
result = gateway.invoke(
"task_authority_probe",
{
"task_concept_id": "#V#task",
"actor_context_source": INTERNAL_MCP_TRUSTED_LOCAL_OPERATOR_SOURCE,
},
).payload
if operator:
assert result == {"success": True, "actor": "#V#manager", "task": "#V#task"}
else:
assert result["success"] is False
assert result["error_code"] == "task_actor_scope_denied"
Loading