Skip to content
Merged
36 changes: 31 additions & 5 deletions application/single_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
import time
from datetime import datetime
from flask import Blueprint, g, make_response
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse

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

from route_frontend_authentication import *
from route_frontend_profile import *
Expand Down Expand Up @@ -923,6 +923,10 @@
'/terms-of-use',
'/terms-of-use/accept',
'/terms-of-use/decline',
'/v2/terms-of-use',
'/api/v2/terms-of-use',
'/api/v2/terms-of-use/accept',
'/api/v2/terms-of-use/decline',
'/robots933456.txt',
'/favicon.ico',
'/acceptable_use_policy.html',
Expand All @@ -943,6 +947,31 @@
return any(path.startswith(prefix) for prefix in TERMS_OF_USE_EXEMPT_PREFIXES)


V2_TERMS_OF_USE_PATH = '/v2/terms-of-use'


def _is_v2_request_path(path):
"""True for V2 SPA pages and V2 API calls, which get the V2 Terms of Use page."""
return path in ('/v2', '/api/v2') or path.startswith('/v2/') or path.startswith('/api/v2/')


def build_terms_of_use_url(path, query_string):
"""Return the Terms of Use page for a blocked request.

V2 pages and V2 API calls are sent to the V2 page so a V2 user never lands on the
classic interstitial. An API path is not a page to return to, so a blocked V2 API call
gets the bare V2 page and the SPA supplies its own location as ``next``.
"""
if _is_v2_request_path(path):
if path.startswith('/api/'):
return V2_TERMS_OF_USE_PATH
return f"{V2_TERMS_OF_USE_PATH}?{urlencode({'next': normalize_path_with_query(path, query_string)})}"
return url_for(
'frontend_terms_of_use.terms_of_use',
next=normalize_path_with_query(path, query_string),
)


@app.before_request
def enforce_terms_of_use():
"""Block authenticated app usage until the current terms of use is accepted."""
Expand All @@ -956,10 +985,7 @@
if has_terms_of_use_acceptance(request_settings, user_id=user_id):
return None

terms_url = url_for(
'frontend_terms_of_use.terms_of_use',
next=normalize_path_with_query(request.path, request.query_string),
)
terms_url = build_terms_of_use_url(request.path, request.query_string)
is_api_request = (
request.accept_mimetypes.accept_json
and not request.accept_mimetypes.accept_html
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.286"
VERSION = "0.261.287"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
2 changes: 2 additions & 0 deletions application/single_app/route_backend_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,8 @@ def user_settings():
# Whether the V2 Admin Settings categories rail is collapsed to icons.
# Separate from the shell and workspace rails so each keeps its own state.
'v2AdminRailCollapsed',
# Whether the V2 Approvals categories rail is collapsed to icons.
'v2ApprovalsRailCollapsed',
# Whether the V2 User Settings sections rail is collapsed to icons.
'v2UserSettingsRailCollapsed',
# Whether the V2 Control Center section rail is collapsed to icons.
Expand Down
129 changes: 129 additions & 0 deletions application/single_app/route_backend_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@
is_source_review_enabled_for_user,
is_url_access_enabled_for_user,
)
from functions_terms_of_use import (
TERMS_OF_USE_RETURN_PATH_SESSION_KEY,
get_terms_of_use_config,
has_terms_of_use_acceptance,
normalize_terms_of_use_return_path,
record_terms_of_use_acceptance,
record_terms_of_use_decline,
)
from functions_workspace_sections import build_workspace_section_availability
from functions_support_latest_features import build_latest_features_payload
from functions_workspace_context import (
Expand Down Expand Up @@ -206,6 +214,9 @@

logger = logging.getLogger(__name__)

# Where an accepted V2 Terms of Use returns when no safe local path was recorded.
V2_TERMS_OF_USE_DEFAULT_RETURN_PATH = "/v2"


# Describes each branding asset an administrator can replace: how to convert the
# upload, which settings keys hold it, and the static path it is written to. The
Expand Down Expand Up @@ -720,6 +731,124 @@
payload, status = {"error": "Workspace details are temporarily unavailable. Please retry."}, 503
return jsonify(payload), status, {"Cache-Control": "no-store"}

@bp.route("/api/v2/terms-of-use", methods=["GET"])
@swagger_route(security=get_auth_security())

Check warning on line 735 in application/single_app/route_backend_v2.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.
@login_required

Check warning on line 736 in application/single_app/route_backend_v2.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.
@user_required

Check warning on line 737 in application/single_app/route_backend_v2.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.
def v2_terms_of_use():
"""Return the Terms of Use the V2 interstitial page shows before the app loads.

Exempt from the Terms of Use gate in app.py, since this is what the gate sends a V2
user to. ``next`` is stored server-side, normalized to a local path, so the accept
call can return the user to where they were without trusting a client value then.
"""
user_id = get_current_user_id()
if not user_id:
return jsonify({"error": "User not authenticated."}), 401, {"Cache-Control": "no-store"}

settings = get_settings() or {}

Check warning on line 749 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
terms_config = get_terms_of_use_config(settings)
return_path = normalize_terms_of_use_return_path(
request.args.get("next"),
fallback=V2_TERMS_OF_USE_DEFAULT_RETURN_PATH,
)
session[TERMS_OF_USE_RETURN_PATH_SESSION_KEY] = return_path
session.modified = True

public_settings = sanitize_settings_for_user(settings)

Check warning on line 758 in application/single_app/route_backend_v2.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.
branding = _build_branding(settings, public_settings)
payload = {
"enabled": terms_config["enabled"],
"required": bool(
terms_config["enabled"]
and not has_terms_of_use_acceptance(settings, user_id=user_id)
),
"title": terms_config["title"],
"message": terms_config["message"] if terms_config["enabled"] else "",
"accept_button_text": terms_config["accept_button_text"],
"decline_button_text": terms_config["decline_button_text"],
"return_path": return_path,
"branding": {
"app_title": branding["app_title"],
"hide_app_title": branding["hide_app_title"],
"show_logo": branding["show_logo"],
"logo_url": branding["logo_url"],
"logo_dark_url": branding["logo_dark_url"],
"classification_banner": branding["classification_banner"],
},
}
return jsonify(payload), 200, {"Cache-Control": "no-store"}

@bp.route("/api/v2/terms-of-use/accept", methods=["POST"])
@swagger_route(security=get_auth_security())

Check warning on line 783 in application/single_app/route_backend_v2.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.
@login_required

Check warning on line 784 in application/single_app/route_backend_v2.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.
@user_required

Check warning on line 785 in application/single_app/route_backend_v2.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.
def v2_accept_terms_of_use():
"""Record acceptance and return the local path the V2 page should reopen."""
user_id = get_current_user_id()
if not user_id:
return jsonify({"error": "User not authenticated."}), 401, {"Cache-Control": "no-store"}

settings = get_settings() or {}

Check warning on line 792 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
terms_config = get_terms_of_use_config(settings)
return_path = normalize_terms_of_use_return_path(
session.pop(TERMS_OF_USE_RETURN_PATH_SESSION_KEY, None),
fallback=V2_TERMS_OF_USE_DEFAULT_RETURN_PATH,
)
session.modified = True

if terms_config["enabled"]:
try:
record_terms_of_use_acceptance(
user_id=user_id,
settings=settings,
source="post_auth_v2",
)
except Exception as exc:
log_event(
"[TERMS_OF_USE] V2 acceptance could not be recorded.",
extra={"user_id": user_id, "error_type": type(exc).__name__},
level=logging.ERROR,
)
return (
jsonify({"error": "Your acceptance could not be saved. Please try again."}),
500,
{"Cache-Control": "no-store"},
)

return jsonify({"success": True, "redirect_url": return_path}), 200, {"Cache-Control": "no-store"}

@bp.route("/api/v2/terms-of-use/decline", methods=["POST"])
@swagger_route(security=get_auth_security())
@login_required
@user_required
def v2_decline_terms_of_use():
"""Record a decline, end the session and return where the browser should go."""
user_id = get_current_user_id()
settings = get_settings() or {}
terms_config = get_terms_of_use_config(settings)

if user_id and terms_config["enabled"]:
try:
record_terms_of_use_decline(
user_id=user_id,
settings=settings,
source="post_auth_v2",
)
except Exception as exc:
log_event(
"[TERMS_OF_USE] V2 decline audit logging failed.",
extra={"user_id": user_id, "error_type": type(exc).__name__},
level=logging.ERROR,
)

session.clear()
return (
jsonify({"success": True, "redirect_url": terms_config["decline_redirect_url"]}),
200,
{"Cache-Control": "no-store"},
)

@bp.route("/api/v2/bootstrap", methods=["GET"])
@swagger_route(security=get_auth_security())
@login_required
Expand Down
24 changes: 22 additions & 2 deletions application/v2_ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import { PublicWorkspacePage } from './pages/PublicWorkspacePage';
import { PublicDirectoryPage } from './pages/PublicDirectoryPage';
import { clearWorkspaceEditorDrafts } from './lib/workspaceEditorDrafts';
import { ContentReviewPage } from './pages/ContentReviewPage';
import { TermsOfUsePage } from './pages/TermsOfUsePage';
import { ApprovalsPage } from './pages/ApprovalsPage';
import { ControlCenterPage } from './pages/ControlCenterPage';

// Dev only: the workflow alert lab. Its one dynamic import is created only when
Expand Down Expand Up @@ -95,13 +97,19 @@ export function App() {
(state) => (state.settings.fontSizePreference as string) || 'm',
);
const location = useLocation();
// Every other call is refused until the terms are accepted, so this page loads nothing
// the shell needs and renders on its own.
const onTermsPage = location.pathname === '/terms-of-use';

useEffect(() => {
clearWorkspaceEditorDrafts();
}, [data?.user?.id, authExpired]);

useEffect(() => {
initializeTheme();
if (onTermsPage) {
return;
}
void load();
// Loaded at startup rather than when the settings page opens, because preferences
// shape the chat interface itself — the conversation list reads one of them.
Expand All @@ -118,7 +126,7 @@ export function App() {
// run that was still going. The restored record has no stream behind it; the run
// history fetched when the panel opens is what settles it.
restorePersistedRuns();
}, [load, loadUserSettings]);
}, [load, loadUserSettings, onTermsPage]);

/**
* Re-read the payload when the tab comes back to the front.
Expand All @@ -136,6 +144,9 @@ export function App() {
* spurious wake-up is one wasted request.
*/
useEffect(() => {
if (onTermsPage) {
return undefined;
}
const onVisible = () => {
if (document.visibilityState === 'visible') {
void refreshBootstrap();
Expand All @@ -150,7 +161,7 @@ export function App() {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
}, [refreshBootstrap]);
}, [refreshBootstrap, onTermsPage]);

useEffect(() => {
const title = data?.branding?.app_title;
Expand Down Expand Up @@ -196,6 +207,10 @@ export function App() {
// list's running tag and the results each run posts back to its chat.
useWorkflowRunTracker(Boolean(data) && !error && workflowRunTrackerShouldRun(data?.features));

if (onTermsPage) {
return <TermsOfUsePage />;
}

if (loading) {
return <BootScreen />;
}
Expand Down Expand Up @@ -229,6 +244,11 @@ export function App() {
<Route path="/control-center" element={<ControlCenterPage />} />
<Route path="/control-center/:section" element={<ControlCenterPage />} />
<Route path="/content-review" element={<ContentReviewPage />} />
{/* Categories and the selected request are real paths, so a notification
or a shared link opens the exact request. */}
<Route path="/approvals" element={<ApprovalsPage />} />
<Route path="/approvals/:category" element={<ApprovalsPage />} />
<Route path="/approvals/:category/:itemId" element={<ApprovalsPage />} />
<Route
path="/agents"
element={
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
// AgentTemplateApprovalsLink.tsx
// Where submitted agent templates are reviewed.
//
// Template review happens in the shared approvals queue, which the classic Approvals page
// serves beside the other requests an administrator handles. This points there rather than
// keeping a second copy of the queue. It opens in a new tab, so leaving for the queue never
// drops settings that have not been saved yet.
// Template review happens in the shared approvals queue, beside the other requests an
// administrator handles. This points there rather than keeping a second copy of the queue.
// It opens in a new tab, so leaving for the queue never drops settings that have not been
// saved yet.

import { ExternalLink } from 'lucide-react';
import { Link } from 'react-router-dom';

export function AgentTemplateApprovalsLink({ help }: { help?: string }) {
return (
<div className="py-3">
{help ? <p className="mb-2 text-xs leading-relaxed text-text-3">{help}</p> : null}
<a
href="/approvals#agent-template-approvals"
<Link
to="/approvals/agent-templates"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-accent hover:underline"
>
Open the approvals queue
<span className="sr-only"> (opens in a new tab)</span>
<ExternalLink size={13} aria-hidden="true" />
</a>
</Link>
</div>
);
}
Loading
Loading