Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
17 changes: 15 additions & 2 deletions application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from functions_latest_features_nav import is_development_env_enabled
from functions_appinsights import log_event
from functions_azure_endpoint_validation import validate_configured_chat_blob_endpoint
from csp_media_sources import build_media_src_directive, parse_csp_media_origins

from functions_environment import load_simplechat_dotenv
from flask import (
Expand Down Expand Up @@ -100,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.217"
VERSION = "0.261.225"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down Expand Up @@ -464,6 +465,18 @@
if TEAMS_FRAME_ANCESTORS:
FRAME_ANCESTORS_DIRECTIVE = f"{FRAME_ANCESTORS_DIRECTIVE} {' '.join(TEAMS_FRAME_ANCESTORS)}"

# External hosts whose audio and video the chat may play in place, for example the media links an
# OpenAPI action returns. Empty by default, which keeps media-src same-origin. Only bare https

Check warning on line 469 in application/single_app/config.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 469 in application/single_app/config.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains dynamic execution, persistence, or system access marker. Recommendation%3A Do not execute changed lifecycle scripts or installers while this finding is unresolved.
# origins are accepted (see csp_media_sources.py); anything else is ignored and logged.
CSP_MEDIA_SRC_ORIGINS, _rejected_csp_media_origins = parse_csp_media_origins(os.getenv('CSP_MEDIA_SRC_ORIGINS', ''))
if _rejected_csp_media_origins:
log_event(

Check warning on line 473 in application/single_app/config.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.
"[CSP] Ignored CSP_MEDIA_SRC_ORIGINS entries that are not bare https origins.",
extra={"rejected_entries": _rejected_csp_media_origins},
level=logging.WARNING,
)
MEDIA_SRC_DIRECTIVE = build_media_src_directive(CSP_MEDIA_SRC_ORIGINS)


# Security Headers Configuration
SECURITY_HEADERS = {
Expand All @@ -477,7 +490,7 @@
"img-src 'self' data: https: blob:; "
"font-src 'self'; "
"connect-src 'self' https: wss: ws:; "
"media-src 'self' blob:; "
f"{MEDIA_SRC_DIRECTIVE}; "
"frame-src 'self' blob:; "
"object-src 'none'; "
f"{FRAME_ANCESTORS_DIRECTIVE}; "
Expand Down
55 changes: 55 additions & 0 deletions application/single_app/csp_media_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# csp_media_sources.py
"""Validate the extra origins an administrator allows in the Content-Security-Policy media-src directive.

Check warning on line 2 in application/single_app/csp_media_sources.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.

Agents and actions can return audio and video hosted on their own domains (for example short-lived,

Check warning on line 4 in application/single_app/csp_media_sources.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.
signed links from an OpenAPI action). The chat can only play those in place when the page's

Check warning on line 5 in application/single_app/csp_media_sources.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.
Content-Security-Policy lists the host in media-src. CSP_MEDIA_SRC_ORIGINS names those hosts.

Check warning on line 6 in application/single_app/csp_media_sources.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.

Only bare https origins are accepted: a host, an optional leading "*." label and an optional port.
Anything else (other schemes, paths, quotes, semicolons, keywords such as 'unsafe-inline', or a bare

Check warning on line 9 in application/single_app/csp_media_sources.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains dynamic execution, persistence, or system access marker. Recommendation%3A Do not execute changed lifecycle scripts or installers while this finding is unresolved.
"*") is rejected, so the setting cannot widen the policy beyond media or inject another directive.

Check warning on line 10 in application/single_app/csp_media_sources.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.

Kept free of application imports so config.py can use it at start-up and tests can import it alone.
"""

import json
import re

_LABEL = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
_ORIGIN_PATTERN = re.compile(rf"^https://(?:\*\.)?{_LABEL}(?:\.{_LABEL})+(?::\d{{1,5}})?$")

Check warning on line 19 in application/single_app/csp_media_sources.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains external connection or remote asset marker. Recommendation%3A Review whether changed code can send prompts, files, credentials, cookies, settings, logs, or user data to a new sink.


def _split(raw_value):
value = str(raw_value or "").strip()
if not value:
return []
if value.startswith("["):
try:
parsed = json.loads(value)
except ValueError:
return [value]
return [str(item) for item in parsed] if isinstance(parsed, list) else [value]
return [item for item in re.split(r"[\s,]+", value) if item]


def parse_csp_media_origins(raw_value):
"""Return (accepted, rejected) origins from a comma, space or JSON-list value.

Accepted origins are lower-cased, stripped of a trailing slash and de-duplicated in order.
"""
accepted = []
rejected = []
for item in _split(raw_value):
candidate = item.strip().rstrip("/").lower()
port = candidate.rsplit(":", 1)[1] if candidate.count(":") == 2 else ""
if _ORIGIN_PATTERN.match(candidate) and (not port or 0 < int(port) <= 65535):
if candidate not in accepted:
accepted.append(candidate)
else:
rejected.append(item)
return accepted, rejected


def build_media_src_directive(origins):
"""The media-src directive: same-origin and blob media, plus the allowed external origins."""
return " ".join(["media-src 'self' blob:", *origins])
2 changes: 1 addition & 1 deletion application/single_app/functions_agent_delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
_MANIFEST_FIELDS = frozenset({
"id", "name", "displayName", "type", "description", "is_enabled", "identity_id",
"endpoint", "auth", "metadata", "additionalFields", "user_id", "group_id",
"is_global", "is_group", "scope", "created_at", "created_by", "modified_at",
"is_global", "is_group", "scope", "scope_id", "created_at", "created_by", "modified_at",
"modified_by", "updated_at", "last_updated", "_attachments", "_etag", "_rid",
"_self", "_ts", "_delegation_scope_type", "_delegation_scope_id",
})
Expand Down
24 changes: 24 additions & 0 deletions application/single_app/functions_azure_maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ def refresh_azure_maps_function_result(function_result: Any) -> Any:
parsed_result = function_result
was_serialized = False
if isinstance(function_result, str):
# Most tool results are not maps, and some are large, so only parse likely candidates.
if AZURE_MAPS_RENDER_TYPE not in function_result:
return function_result
try:
parsed_result = json.loads(function_result)
was_serialized = True
Expand Down Expand Up @@ -234,6 +237,27 @@ def refresh_azure_maps_citation_payloads(citations: Any) -> Any:
return [refresh_azure_maps_citation_payload(citation) for citation in citations]


def refresh_azure_maps_message_citations(messages: Any) -> Any:
"""Return messages whose stored map citations carry a fresh tile proxy token.

Tile tokens expire, so a map in an older message only loads its tiles if the
token is reissued when the message is read.
"""
if not isinstance(messages, list):
return messages

refreshed_messages = []
for message in messages:
if isinstance(message, dict) and isinstance(message.get("agent_citations"), list):
message = {
**message,
"agent_citations": refresh_azure_maps_citation_payloads(message["agent_citations"]),
}
refreshed_messages.append(message)

return refreshed_messages


def _find_inline_map_block_end(message_content: str, start_index: int) -> Optional[int]:
payload_index = start_index + len(AZURE_MAPS_INLINE_BLOCK_PREFIX)
if payload_index >= len(message_content) or message_content[payload_index] != "{":
Expand Down
4 changes: 2 additions & 2 deletions application/single_app/functions_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -6822,6 +6822,8 @@ def upload_to_blob(temp_file_path, user_id, document_id, blob_filename, update_c
return None

try:
# The status write changes this record's etag, so it must precede the read that guards the final write.
update_callback(status=f"Uploading {blob_filename} to Blob Storage...")
cosmos_container = _get_documents_container(group_id=group_id, public_workspace_id=public_workspace_id)
current_document = cosmos_container.read_item(item=document_id, partition_key=document_id)
storage_account_container_name = current_document.get("blob_container") or _get_blob_container_name(
Expand Down Expand Up @@ -6875,8 +6877,6 @@ def upload_to_blob(temp_file_path, user_id, document_id, blob_filename, update_c

metadata = {k: v for k, v in metadata.items() if v is not None}

update_callback(status=f"Uploading {blob_filename} to Blob Storage...")

with open(temp_file_path, "rb") as f:
blob_client.upload_blob(f, overwrite=True, metadata=metadata)

Expand Down
57 changes: 56 additions & 1 deletion application/single_app/functions_simplechat_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@

SIMPLECHAT_PLUGIN_TYPE = "simplechat"
SIMPLECHAT_DEFAULT_ENDPOINT = "simplechat://internal"
# Marks a message an agent posted through add_conversation_message. Clients render it as
# markdown, the same way they render the agent's replies (see isAgentPostedMessage in the V2 UI).
AGENT_POSTED_MESSAGE_METADATA = {"posted_via": "agent_action", "content_format": "markdown"}
GENERATED_CHAT_ARTIFACT_LIFECYCLE_STAGED = "staged"
GENERATED_CHAT_ARTIFACT_LIFECYCLE_PUBLISHED = "published"
GENERATED_CHAT_ARTIFACT_LIFECYCLE_ROLLED_BACK = "rolled_back"
Expand Down Expand Up @@ -1127,6 +1130,12 @@ def add_conversation_message_for_current_user(
content: str,
reply_to_message_id: str = "",
) -> Dict[str, Any]:
"""Post a message an agent wrote, through the SimpleChat action, as the current user.

The message is stored as the user's own, because it is sent with their permissions, and is
marked with AGENT_POSTED_MESSAGE_METADATA so clients render it as markdown, the way the
agent's replies are rendered, while messages people type stay plain text.
"""
current_user_info = _require_current_user_info()
normalized_conversation_id = str(conversation_id or "").strip()
normalized_content = str(content or "").strip()
Expand Down Expand Up @@ -1154,6 +1163,7 @@ def add_conversation_message_for_current_user(
current_user_info=current_user_info,
content=normalized_content,
reply_to_message_id=normalized_reply_to_message_id,
extra_metadata=AGENT_POSTED_MESSAGE_METADATA,
)
return {
"conversation": updated_conversation,
Expand Down Expand Up @@ -1182,6 +1192,7 @@ def add_conversation_message_for_current_user(
current_user,
normalized_content,
reply_to_message_id=normalized_reply_to_message_id,
extra_metadata=AGENT_POSTED_MESSAGE_METADATA,
)
create_collaboration_message_notifications(updated_conversation, message_doc)
return {
Expand Down Expand Up @@ -2059,10 +2070,12 @@ def invite_group_conversation_members_for_current_user(
if not is_group_collaboration_conversation(conversation_doc):
raise LookupError(CONVERSATION_ACCESS_ERROR)

group_id = str(((conversation_doc.get("scope") or {}).get("group_id")) or "").strip()
participants_to_add = _build_invited_participants(
creator_user=current_user,
participants=participants,
participant_identifiers=participant_identifiers,
group_members=_group_member_summaries(find_group_by_id(group_id) if group_id else None),
)
if not participants_to_add:
raise ValueError("At least one participant identifier is required")
Expand Down Expand Up @@ -2339,6 +2352,7 @@ def _build_invited_participants(
creator_user: Dict[str, str],
participants: Optional[Iterable[Dict[str, Any]]] = None,
participant_identifiers: Any = None,
group_members: Optional[List[Dict[str, str]]] = None,
) -> List[Dict[str, str]]:
invited_participants: List[Dict[str, str]] = []
seen_user_ids = {creator_user.get("user_id")}
Expand All @@ -2354,7 +2368,9 @@ def _build_invited_participants(
invited_participants.append(normalized_participant)

for raw_identifier in _split_participant_identifiers(participant_identifiers):
resolved_user = resolve_directory_user(user_identifier=raw_identifier)
resolved_user = _match_group_member(group_members, raw_identifier)
if resolved_user is None:
resolved_user = resolve_directory_user(user_identifier=raw_identifier)
normalized_participant = normalize_collaboration_user(resolved_user)
if not normalized_participant:
continue
Expand All @@ -2367,6 +2383,42 @@ def _build_invited_participants(
return invited_participants


def _group_member_summaries(group_doc: Optional[Dict[str, Any]]) -> List[Dict[str, str]]:
"""The group's owner and members as collaboration user summaries, once each."""
if not isinstance(group_doc, dict):
return []
owner = group_doc.get("owner") if isinstance(group_doc.get("owner"), dict) else {}
raw_members = [
{"userId": owner.get("id"), "displayName": owner.get("displayName"), "email": owner.get("email")},
*(group_doc.get("users") or []),
]
members: Dict[str, Dict[str, str]] = {}
for raw_member in raw_members:
summary = normalize_collaboration_user(raw_member)
if summary:
members.setdefault(summary["user_id"], summary)
return list(members.values())


def _match_group_member(group_members: Optional[List[Dict[str, str]]], identifier: str) -> Optional[Dict[str, str]]:
"""The one group member an invite identifier names by user ID, email or display name.

Matching the group's own records needs no directory lookup, which a scheduled
workflow run cannot make. An identifier that names no member, or several, is left
to the directory.
"""
wanted = str(identifier or "").strip().lower()
matches = [
member for member in group_members or []
if wanted in {
str(member.get("user_id") or "").lower(),
str(member.get("email") or "").lower(),
str(member.get("display_name") or "").lower(),
}
]
return matches[0] if len(matches) == 1 else None


def _split_participant_identifiers(raw_identifiers: Any) -> List[str]:
if raw_identifiers is None:
return []
Expand All @@ -2388,6 +2440,7 @@ def _persist_personal_conversation_message(
current_user_info: Dict[str, str],
content: str,
reply_to_message_id: Optional[str] = None,
extra_metadata: Optional[Dict[str, Any]] = None,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
conversation_id = str(conversation_item.get("id") or "").strip()
if not conversation_id:
Expand Down Expand Up @@ -2434,6 +2487,8 @@ def _persist_personal_conversation_message(
},
},
}
if extra_metadata:
message_doc["metadata"].update(extra_metadata)

cosmos_messages_container.upsert_item(message_doc)

Expand Down
27 changes: 25 additions & 2 deletions application/single_app/functions_workflow_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@
is_cross_format_compare_one_to_many_enabled,
is_tabular_processing_enabled,
normalize_model_endpoints,
read_user_settings_snapshot,
resolve_model_endpoint_foundry_scope,
)
from functions_tabular_parity_contract import (
Expand Down Expand Up @@ -5909,6 +5910,27 @@ def _get_workflow_runner_app():
return _workflow_runner_app


def _workflow_owner_identity(user_id):
"""Return the owner's stored display name and email for a run without a signed-in session.

Without them a background run acts as the bare object ID, which then shows as the name
on the groups and messages it creates and is written back over the stored display name.
"""
if not user_id:
return '', ''
try:
# The run acts as this owner, whose id comes from the stored workflow rather than a caller.
user_doc = read_user_settings_snapshot(user_id, allow_cross_user=True)
except AzureError as exc:
log_event(
'[WorkflowRunner] Owner profile unavailable for a background run',
extra={'user_id': user_id, 'error_type': type(exc).__name__},
level=logging.WARNING,
)
return '', ''
return str(user_doc.get('display_name') or '').strip(), str(user_doc.get('email') or '').strip()


@contextmanager
def _ensure_execution_context(user_id):
created_context = None
Expand All @@ -5920,13 +5942,14 @@ def _ensure_execution_context(user_id):
reuse_existing = session_user_id == str(user_id or '').strip()

if not reuse_existing:
display_name, email = _workflow_owner_identity(str(user_id or '').strip())
created_context = _get_workflow_runner_app().test_request_context('/api/internal/workflows/run')
created_context.push()
session['user'] = {
'oid': user_id,
'roles': ['User'],
'preferred_username': '',
'name': user_id,
'preferred_username': email,
'name': display_name or user_id,
}

try:
Expand Down
3 changes: 2 additions & 1 deletion application/single_app/model_endpoint_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@


ANTHROPIC_MODEL_MARKERS = ("claude",)
OPENAI_REASONING_MODEL_PREFIXES = ("gpt-5", "o1", "o3", "o4")
OPENAI_REASONING_MODEL_PREFIXES = ("gpt-5", "gpt-6", "o1", "o3", "o4")
MODEL_CONTEXT_MODE_SYSTEM = "system"
MODEL_CONTEXT_MODE_FOLD_LATEST_USER = "fold_latest_user"

Expand Down Expand Up @@ -111,6 +111,7 @@ def is_openai_reasoning_model(self) -> bool:
return (
normalized_model_name.startswith(OPENAI_REASONING_MODEL_PREFIXES)
or "gpt-5" in normalized_model_name
or "gpt-6" in normalized_model_name
)

@property
Expand Down
2 changes: 2 additions & 0 deletions application/single_app/route_backend_collaboration.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
apply_visual_style,
)
from functions_notifications import mark_collaboration_message_notifications_read_for_conversation
from functions_azure_maps import refresh_azure_maps_message_citations
from functions_message_artifacts import make_json_serializable
from functions_m365_runtime import read_pending_m365_chat_request
from functions_m365_approvals import M365ApprovalRequired, M365PolicyError
Expand Down Expand Up @@ -1714,6 +1715,7 @@ def get_collaboration_messages_api(conversation_id):
allow_pending=True,
)
messages = [serialize_collaboration_message(doc) for doc in list_collaboration_messages(conversation_id)]
messages = refresh_azure_maps_message_citations(messages)
messages = sanitize_saved_analysis_messages(messages, current_user['user_id'])
attach_generated_file_approval_state(messages, current_user['user_id'])
messages = hydrate_m365_pending_action_cards(messages, current_user['user_id'], conversation_id)
Expand Down
2 changes: 2 additions & 0 deletions application/single_app/route_backend_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
serialize_chat_upload_workspace_documents_for_conversation,
)
from functions_group import get_user_groups
from functions_azure_maps import refresh_azure_maps_message_citations
from functions_message_artifacts import (
build_message_artifact_payload_map,
filter_assistant_artifact_items,
Expand Down Expand Up @@ -1139,6 +1140,7 @@ def api_get_messages():

all_items = sanitize_saved_analysis_messages(all_items, user_id)
all_items = hydrate_agent_citations_from_artifacts(all_items, artifact_payload_map)
all_items = refresh_azure_maps_message_citations(all_items)
try:
all_items = public_history_messages(all_items, user_id)
except (
Expand Down
Loading
Loading