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
27 changes: 21 additions & 6 deletions application/single_app/agent_delegation_runtime.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# agent_delegation_runtime.py
"""Isolated asynchronous agent calls.

Version: 0.261.127
Version: 0.261.270
Implemented in: 0.261.093
Optional step scope entered inside the agent's bridge in: 0.261.270

Check warning on line 6 in application/single_app/agent_delegation_runtime.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Provider and loader imports are deliberately lazy: plugin discovery imports the
Call agent class before the application's Semantic Kernel loader is initialized.
Expand Down Expand Up @@ -301,8 +302,13 @@
return messages, citations


async def execute_target(target, task, context, frame):
"""Invoke precisely this canonical target; never select a default or by name."""
async def execute_target(target, task, context, frame, *, scope=None):
"""Invoke precisely this canonical target; never select a default or by name.

``scope``, when given, returns a context manager entered inside the target's own bridge,
so request state it installs, such as an orchestration step's Microsoft 365 context,
reaches the agent's tools.

Check warning on line 310 in application/single_app/agent_delegation_runtime.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"""
active = current_agent_execution()
if active is not None and active.invocation_capture is not None and (
frame.invocation_capture is not active.invocation_capture
Expand All @@ -320,7 +326,7 @@
_prepare_captured_agent(target, frame.invocation_settings, frame.identity, frame.invocation_capture)
bridge = frame.identity.bridge(target) if frame.identity.bridge else nullcontext()
kernel = None
with bridge:
with bridge, (scope(target) if scope is not None else nullcontext()):
_check_cancelled(frame)
_preflight_captured_agent(target, frame)
settings = deepcopy(frame.invocation_settings) if frame.invocation_settings is not None else get_settings()
Expand Down Expand Up @@ -632,8 +638,12 @@

async def invoke_scoped_agent(
reference, task, *, identity, budget, cancel_requested=None, settings=None, invocation_capture=None,
scope=None,
):
"""Worker-safe root execution; Flask compatibility belongs to the captured bridge."""
"""Worker-safe root execution; Flask compatibility belongs to the captured bridge.

``scope`` is passed to ``execute_target`` and entered inside the agent's bridge.

Check warning on line 645 in application/single_app/agent_delegation_runtime.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Check warning on line 645 in application/single_app/agent_delegation_runtime.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
"""
parent = current_agent_execution()
if parent is not None and parent.invocation_capture is not None:
parent.invocation_capture.refuse()
Expand All @@ -651,7 +661,12 @@
invocation_settings=deepcopy(settings) if invocation_capture is not None and settings is not None else None,
)
with agent_execution(frame) as frame:
result = await await_agent_operation(execute_target(canonical, task, "", frame), frame)
# The existing call shape is kept when no scope is supplied.
target = (
execute_target(canonical, task, "", frame, scope=scope) if scope is not None
else execute_target(canonical, task, "", frame)
)
result = await await_agent_operation(target, frame)

Check warning on line 669 in application/single_app/agent_delegation_runtime.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
if frame.invocation_capture is not None:
frame.invocation_capture.require_valid(captured=True)
return result
17 changes: 17 additions & 0 deletions application/single_app/collaboration_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@
return str(value or '').strip()


def is_shared_conversation_backing(conversation_item):

Check warning on line 45 in application/single_app/collaboration_models.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"""Whether a personal conversation record is stored under its own shared conversation's id.

Check warning on line 46 in application/single_app/collaboration_models.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Orchestrate keeps a shared conversation's plans in such a hidden record (0.261.270). Its id is

Check warning on line 48 in application/single_app/collaboration_models.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Check warning on line 48 in application/single_app/collaboration_models.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
the id every participant's browser uses for the shared conversation, so a lookup by that id

Check warning on line 49 in application/single_app/collaboration_models.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
must treat the record as part of the shared conversation, never as anyone's personal one.
The classic assistant's hidden source conversation has an id of its own and is not one.
"""
item = conversation_item if isinstance(conversation_item, dict) else {}
conversation_id = _clean_string(item.get('id'))
return bool(
conversation_id
and _clean_string(item.get('conversation_kind')) == COLLABORATION_SOURCE_KIND
and _clean_string(item.get('collaboration_conversation_id')) == conversation_id
)


def normalize_collaboration_user(raw_user, fallback_user_id=None):
raw_user = raw_user or {}
if not isinstance(raw_user, dict):
Expand Down
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.269"
VERSION = "0.261.270"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
20 changes: 19 additions & 1 deletion application/single_app/functions_collaboration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2239,7 +2239,11 @@
if source_role == 'image':
message_metadata['last_message_preview'] = '[Uploaded image]' if bool(source_metadata.get('is_user_upload')) else '[Generated image]'

return (*_save_collaboration_message_doc(conversation_doc, collaboration_message), True)
saved_message, saved_conversation = _save_collaboration_message_doc(
conversation_doc,
collaboration_message,
)
return saved_message, saved_conversation, True


def _refresh_collaboration_conversation_message_summary(conversation_doc):
Expand Down Expand Up @@ -2815,6 +2819,20 @@

_cancel_collaboration_pending_deliveries(conversation_doc)

# Orchestrate's backing record, stored under this shared conversation's id, goes with it.
# Removed first, so a failure leaves the shared conversation intact for a retry.
from functions_orchestration_collaboration import delete_orchestration_backing
Comment thread
paullizer marked this conversation as resolved.
delete_orchestration_backing(
conversation_doc,
archiving_enabled=archiving_enabled,
expected_user_id=expected_source_user_id,
conversation_container=cosmos_conversations_container,
message_container=cosmos_messages_container,
archived_conversation_container=cosmos_archived_conversations_container if archiving_enabled else None,
archived_message_container=cosmos_archived_messages_container if archiving_enabled else None,
not_found_error=CosmosResourceNotFoundError,
)

if is_personal_collaboration_conversation(conversation_doc):
revocation_conversation_doc = deepcopy(conversation_doc)
revocation_conversation_doc['accepted_participant_ids'] = []
Expand Down
15 changes: 11 additions & 4 deletions application/single_app/functions_conversation_metadata.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# functions_conversation_metadata.py

from datetime import datetime
from collaboration_models import is_shared_conversation_backing
from config import *
from functions_settings import get_settings
from functions_authentication import get_current_user_info
Expand Down Expand Up @@ -64,7 +65,11 @@ def _normalize_scope_id_list(raw_ids):


def _get_conversation_item_with_source(conversation_id):
"""Load a conversation from the legacy or collaboration store."""
"""Load a conversation from the legacy or collaboration store.

Orchestrate's backing record shares its shared conversation's id, and the shared
conversation is the one returned for that id.
"""
normalized_conversation_id = str(conversation_id or '').strip()
if not normalized_conversation_id:
raise CosmosResourceNotFoundError(message='Conversation not found')
Expand All @@ -74,10 +79,12 @@ def _get_conversation_item_with_source(conversation_id):
item=normalized_conversation_id,
partition_key=normalized_conversation_id
)
return conversation_item, 'legacy'
if not is_shared_conversation_backing(conversation_item):
return conversation_item, 'legacy'
except CosmosResourceNotFoundError:
conversation_item = get_collaboration_conversation(normalized_conversation_id)
return conversation_item, 'collaboration'
pass
conversation_item = get_collaboration_conversation(normalized_conversation_id)
return conversation_item, 'collaboration'


def _build_primary_context_from_scope_selection(
Expand Down
28 changes: 28 additions & 0 deletions application/single_app/functions_m365_approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
Dependencies are resolved at operation time, not while config is bootstrapping.
The approval container keeps its /group_id partition; that field is a physical
partition only and never confers group or administrator authorization.

Version: 0.261.270
Shared by your own request in: 0.261.270. In a shared conversation, an interactive
request by the data owner (a context with ``shared_by_request``) is their consent to share
the sources it reads with that conversation; it is audited as ``shared_by_request`` and
needs no pending approval. Workflow Run as and history publication approvals are unchanged.
"""

import copy
Expand Down Expand Up @@ -711,6 +717,24 @@ def _grant_use(self, context, source, approval, effective):
"decision_event_id": approval.get("decision_event_id"), **effective,
}

def _request_consent(self, context, source):
"""Record that the data owner shared a source by asking for it in a shared conversation.

The grant has the shape of an approved request-only grant, so publishing evidence the
request captured references this request's audit event in place of an approval.
"""
event_id = f"m365-request-{material_fingerprint([request_scope_fingerprint(context), source])}"
event = self._read(event_id, context.data_user_id) or self._create_once(self._audit_document(
context.data_user_id, "shared_by_request", event_id,
source=source, context=approval_context(context),
effective_grant={"effective_duration": "request"},
))
return {
"source": source, "sharing_required": False, "shared_by_request": True,
"approval_id": event_id, "audit_id": event_id, "decision_event_id": event_id,
"effective_duration": "request", "acknowledged_at": event["created_at"], "expires_at": None,
}

def authorize_sources(self, context, sources):
if not isinstance(sources, Mapping) or not sources:
raise ValueError("At least one Microsoft 365 source is required.")
Expand All @@ -719,6 +743,10 @@ def authorize_sources(self, context, sources):
return {source: {"source": source, "sharing_required": False} for source in sources}
if not context.conversation_id or not context.audience_version:
raise M365PolicyError("m365_context_required", "An authoritative conversation audience is required.")
if context.shared_by_request:
# Asking for your own Microsoft 365 data in a shared conversation is your consent to
# share it with that conversation; workflows and history publication still ask.
return {source: self._request_consent(context, source) for source in sources}
state = self._state(context.data_user_id)
scope = request_scope_fingerprint(context)
granted = {}
Expand Down
10 changes: 9 additions & 1 deletion application/single_app/functions_m365_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,18 @@ class M365ExecutionContext:
workflow_fingerprint: str | None = None
connection_id: str | None = None
group_id: str | None = None
# An interactive request in a shared conversation by the data owner, whose own request is
# their consent to share the sources it reads there. Kept out of the approval fingerprint.
shared_by_request: bool = False

def __post_init__(self):
if type(self.shared) is not bool or not isinstance(self.action_configs, Mapping):
if (
type(self.shared) is not bool or type(self.shared_by_request) is not bool
or not isinstance(self.action_configs, Mapping)
):
raise ValueError("Invalid authoritative Microsoft 365 execution context.")
if self.shared_by_request and (not self.shared or self.workflow_id):
raise ValueError("Only an interactive shared request can share by request.")
object.__setattr__(self, "action_configs", _immutable(self.action_configs))
approval_context(self)
if not self.workflow_id and self.actor_user_id != self.data_user_id:
Expand Down
9 changes: 8 additions & 1 deletion application/single_app/functions_m365_pending_delivery.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# functions_m365_pending_delivery.py
"""Claimed M365 delivery using reviewed material and current subject authorization."""
"""Claimed M365 delivery using reviewed material and current subject authorization.

Version: 0.261.270
A chat delivery keeps its request's ``shared_by_request`` consent in: 0.261.270, so sending a
reviewed action in a shared conversation needs no approval its request didn't need.
"""

from copy import deepcopy
from datetime import datetime, timedelta, timezone
Expand All @@ -16,10 +21,12 @@


_dependencies = {}
# A delivery recorded before 0.261.270 has no ``shared_by_request`` and restores it as False.
_CONTEXT_FIELDS = (
"actor_user_id", "data_user_id", "tenant_id", "conversation_id", "shared",
"request_id", "workflow_id", "run_id", "step_id", "audience_version",
"binding_id", "workflow_fingerprint", "connection_id", "group_id", "agent_id",
"shared_by_request",
)
_TERMINAL = {"sent", "cancelled", "failed", "recovery_required"}
DELIVERY_BINDING_VERSION = 1
Expand Down
55 changes: 38 additions & 17 deletions application/single_app/functions_m365_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
cosmos_conversations_container,
cosmos_m365_execution_runs_container,
)
from collaboration_models import is_shared_conversation_backing
from functions_appinsights import log_event
from functions_collaboration import (
assert_user_can_participate_in_collaboration_conversation,
Expand Down Expand Up @@ -230,6 +231,8 @@ def initialize_m365_chat_context(user_id, conversation_id, *, allow_new=False):
actor_user_id=user_id, data_user_id=user_id, tenant_id=TENANT_ID,
conversation_id=conversation_id, shared=shared is not None,
request_id=request_id, audience_version=_audience_version(conversation, shared),
# Your own chat request in a shared conversation is your consent to share what it reads.
shared_by_request=shared is not None,
)
g.m365_request_fingerprint = fingerprint
return install_m365_context(context)
Expand Down Expand Up @@ -564,11 +567,26 @@ def _step_action_manifest(action):


def step_selected_m365_manifests(selection, actor_user_id):
"""Resolve an orchestration action step's selection from current storage, never the plan.
"""Resolve an orchestration step's Microsoft 365 selection from current storage, never the plan.

The selection names the approved step's saved action by its exact reference, and the
catalog reauthorizes that reference on every read.
An action step names the approved step's saved action by its exact reference, and the
catalog reauthorizes that reference on every read. An agent step names the step's agent,
whose Microsoft 365 actions and overrides resolve the way a chat-selected agent's do.
"""
if isinstance(selection, dict) and selection.get("kind") == "agent":
agent = selection.get("agent")
if not isinstance(agent, dict) or not (agent.get("id") or agent.get("name")):
raise M365PolicyError(
"m365_action_selection_unavailable",
"The selected Microsoft 365 actions could not be resolved.",
)
manifests, _ = workflow_m365_manifests({
"user_id": actor_user_id,
"group_id": agent.get("group_id") if agent.get("is_group") else None,
"selected_agent": agent,
"tasks": [],
})
return manifests
if not isinstance(selection, dict) or selection.get("kind") != "action":
raise M365PolicyError(
"m365_action_selection_unavailable",
Expand Down Expand Up @@ -697,37 +715,38 @@ def _stopping_approval_id(error):

@contextmanager
def step_m365_context(*, user_id, conversation_id, request_id, selection, origin=None):
"""Authorize Microsoft 365 for one orchestration action step inside its execution bridge.
"""Authorize Microsoft 365 for one orchestration action or agent step inside its bridge.

A plan step runs in a fresh request context, so the chat request's Microsoft 365
context never reaches it. This context uses the signed-in user as actor and data user,
selects only the step's own action, and never enables send, invite or read-state
functions. Shared conversations are refused: their source-sharing approvals resume chat
requests, not plan steps. Delegated sign-in is checked here, before any model call or
Microsoft Graph request.
selects only the step's own action or agent, and never enables send, invite or
read-state functions. In a shared conversation it carries that conversation's real
audience; the user's own request is their consent to share the sources it reads there.
Delegated sign-in is checked here, before any model call or Microsoft Graph request. An
agent step whose agent loads no Microsoft 365 action gets no context and leaves no
request record.
"""
if not has_request_context() or (session.get("user") or {}).get("oid") != user_id:
raise M365PolicyError("m365_session_required", "Sign in again to use Microsoft 365.")
conversation, _access, shared = _conversation_access(user_id, conversation_id)
if conversation is None or conversation.get("user_id") != user_id:
raise M365PolicyError("conversation_not_found", "Conversation not found.")
if shared is not None:
raise M365PolicyError(
"m365_shared_conversation_unsupported",
"Plans can't use Microsoft 365 in a shared conversation.",
)
selected = step_selected_m365_manifests(selection, user_id)
if not selected and selection.get("kind") == "agent":
yield None
return
previous_state = {name: value for name, value in vars(g).items() if name.startswith("m365_")}
for name in previous_state:
delattr(g, name)
setattr(g, M365_STEP_SELECTION_KEY, deepcopy(selection))
try:
context = M365ExecutionContext(
actor_user_id=user_id, data_user_id=user_id, tenant_id=TENANT_ID,
conversation_id=conversation_id, shared=False, request_id=request_id,
audience_version=_audience_version(conversation, None),
conversation_id=conversation_id, shared=shared is not None, request_id=request_id,
audience_version=_audience_version(conversation, shared),
shared_by_request=shared is not None,
)
install_m365_context(context)
selected = step_selected_m365_manifests(selection, user_id)
manifests = _readable_m365_manifests(selected)
if not manifests:
if selected:
Expand Down Expand Up @@ -909,7 +928,9 @@ def authorize_m365_conversation_audit(user_id, conversation_id):

def resolve_m365_audit_conversation_id(user_id, conversation_id):
conversation, _access, _shared = _conversation_access(user_id, conversation_id)
if conversation is not None:
# Orchestrate's backing record shares its shared conversation's id; the shared
# conversation's Microsoft 365 records belong to its classic source conversation.
if conversation is not None and not is_shared_conversation_backing(conversation):
return conversation_id
shared = get_collaboration_conversation(conversation_id)
assert_user_can_participate_in_collaboration_conversation(user_id, shared)
Expand Down
5 changes: 4 additions & 1 deletion application/single_app/functions_mcp_server_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from azure.cosmos.exceptions import CosmosResourceNotFoundError

from background_tasks import acquire_distributed_task_lock, release_distributed_task_lock
from collaboration_models import MEMBERSHIP_STATUS_ACCEPTED
from collaboration_models import MEMBERSHIP_STATUS_ACCEPTED, is_shared_conversation_backing
from config import (
cosmos_conversations_container,
cosmos_messages_container,
Expand Down Expand Up @@ -257,6 +257,9 @@ def _authorize_personal_conversation_read(delegated_user_id, conversation_id):
)
except CosmosResourceNotFoundError:
conversation_item = None
# Orchestrate's backing record shares its shared conversation's id and is part of it.
if is_shared_conversation_backing(conversation_item):
conversation_item = None

if conversation_item is not None:
if str(conversation_item.get("user_id") or "").strip() != delegated_user_id:
Expand Down
Loading
Loading