| """ |
| Amanat - Chainlit web UI with Auth0 authentication. |
| |
| Run with: chainlit run app.py |
| """ |
|
|
| import base64 |
| import hashlib |
| import json |
| import logging |
| import os |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Optional |
|
|
| from cryptography.fernet import Fernet |
| from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC |
| from cryptography.hazmat.primitives import hashes |
|
|
| import chainlit as cl |
| import plotly.graph_objects as go |
| from chainlit import User |
|
|
| from amanat.tools.scanner import execute_tool |
| from amanat.agent import ( |
| SYSTEM_PROMPT, _build_system_prompt, create_agent, |
| set_access_token, REMEDIATION_TOOLS, |
| ) |
| from amanat.auth import Auth0TokenVault |
|
|
|
|
| |
| from chainlit.oauth_providers import Auth0OAuthProvider |
|
|
| _original_auth0_init = Auth0OAuthProvider.__init__ |
| _original_auth0_get_token = Auth0OAuthProvider.get_token |
|
|
| def _patched_auth0_init(self): |
| _original_auth0_init(self) |
| self.authorize_params["audience"] = os.environ.get( |
| "AUTH0_AUDIENCE_OVERRIDE", "https://amanat.local/api" |
| ) |
| self.authorize_params["scope"] = "openid profile email offline_access" |
|
|
| async def _patched_auth0_get_token(self, code: str, url: str): |
| """Return access_token but stash refresh_token for Token Vault.""" |
| json_content = await self.get_raw_token_response(code, url) |
| token = json_content.get("access_token", "") |
| |
| Auth0OAuthProvider._last_token_response = json_content |
| if not token: |
| from starlette.exceptions import HTTPException |
| raise HTTPException(status_code=400, detail="Access token missing") |
| return token |
|
|
| Auth0OAuthProvider.__init__ = _patched_auth0_init |
| Auth0OAuthProvider.get_token = _patched_auth0_get_token |
|
|
| |
| |
| |
| from chainlit.oauth_providers import providers |
| for i, p in enumerate(providers): |
| if getattr(p, "id", None) == "auth0": |
| providers[i] = Auth0OAuthProvider() |
| break |
|
|
|
|
| |
| AUDIT_DIR = Path("audit-logs") |
| AUDIT_DIR.mkdir(exist_ok=True) |
|
|
| |
| _AUDIT_SALT = b"amanat-audit-log-encryption-v1" |
|
|
|
|
| def _get_fernet() -> Fernet: |
| """Derive a Fernet key from CHAINLIT_AUTH_SECRET using PBKDF2.""" |
| secret = os.environ.get("CHAINLIT_AUTH_SECRET", "dev-fallback-secret") |
| kdf = PBKDF2HMAC( |
| algorithm=hashes.SHA256(), |
| length=32, |
| salt=_AUDIT_SALT, |
| iterations=480_000, |
| ) |
| key = base64.urlsafe_b64encode(kdf.derive(secret.encode())) |
| return Fernet(key) |
|
|
|
|
| def _audit_log(session_id: str, event: str, data: dict | None = None): |
| """Append an encrypted audit event to the session log file.""" |
| log_file = AUDIT_DIR / f"{session_id}.jsonl.enc" |
| entry = { |
| "ts": datetime.now(timezone.utc).isoformat(), |
| "event": event, |
| **(data or {}), |
| } |
| fernet = _get_fernet() |
| encrypted_line = fernet.encrypt(json.dumps(entry).encode()).decode() |
| with open(log_file, "a") as f: |
| f.write(encrypted_line + "\n") |
|
|
|
|
| def decrypt_audit_log(path: str | Path) -> list[dict]: |
| """Decrypt all entries in an encrypted audit log file.""" |
| fernet = _get_fernet() |
| entries = [] |
| with open(path) as f: |
| for line in f: |
| line = line.strip() |
| if line: |
| plaintext = fernet.decrypt(line.encode()).decode() |
| entries.append(json.loads(plaintext)) |
| return entries |
|
|
| |
| |
| |
| |
|
|
| from starlette.responses import HTMLResponse, RedirectResponse, JSONResponse |
| from starlette.routing import Route |
| from chainlit.server import app as chainlit_app |
| import httpx as _httpx |
| import secrets as _secrets |
| from amanat.auth import CONNECTIONS |
|
|
| DOMAIN = os.environ.get("AUTH0_DOMAIN", "") |
| _CLIENT_ID = os.environ.get("AUTH0_CLIENT_ID", "") |
| _CLIENT_SECRET = os.environ.get("AUTH0_CLIENT_SECRET", "") |
|
|
| |
| _connect_sessions: dict[str, dict] = {} |
|
|
| |
| _SERVICE_DISPLAY = { |
| "onedrive": {"name": "OneDrive", "icon": "📁"}, |
| "outlook": {"name": "Outlook", "icon": "📧"}, |
| "slack": {"name": "Slack", "icon": "💬"}, |
| "github": {"name": "GitHub", "icon": "🐙"}, |
| } |
|
|
|
|
| async def connect_service(request): |
| """Start Connected Accounts flow to link any service via Token Vault.""" |
| service = request.path_params.get("service", "") |
| conn_config = CONNECTIONS.get(service) |
| if not conn_config: |
| return JSONResponse({"error": f"Unknown service: {service}"}, 400) |
|
|
| |
| if DEMO_TOOLS: |
| display = _SERVICE_DISPLAY.get(service, {}) |
| name = display.get("name", service) |
| base_url = str(request.base_url).rstrip("/") |
| html = f"""<!DOCTYPE html> |
| <html><head><style> |
| body {{ background: #0e1629; color: #c8d4e0; font-family: 'Noto Sans', sans-serif; |
| display: flex; align-items: center; justify-content: center; height: 100vh; }} |
| .card {{ text-align: center; padding: 40px; }} |
| h2 {{ color: #14A89B; }} |
| a {{ color: #14A89B; text-decoration: none; font-weight: 600; }} |
| </style></head><body> |
| <div class="card"> |
| <h2>{display.get('icon', '')} {name} Connected</h2> |
| <p>Service linked via Auth0 Token Vault (demo mode).</p> |
| <p>Scopes: <code>{', '.join(conn_config.get('scopes', []))}</code></p> |
| <p><a href="{base_url}">Return to Amanat</a></p> |
| </div></body></html>""" |
| return HTMLResponse(html) |
|
|
| |
| try: |
| with open("/tmp/amanat_tokens.txt") as f: |
| content = f.read() |
| rt = content.split("refresh_token: ", 1)[1].strip() if "refresh_token: " in content else "" |
| if not rt or len(rt) < 10: |
| return JSONResponse({"error": "No refresh token. Log in first."}, 400) |
| except FileNotFoundError: |
| return JSONResponse({"error": "No login tokens found. Log in first."}, 400) |
|
|
| |
| async with _httpx.AsyncClient() as hc: |
| resp = await hc.post(f"https://{DOMAIN}/oauth/token", data={ |
| "grant_type": "refresh_token", |
| "client_id": _CLIENT_ID, |
| "client_secret": _CLIENT_SECRET, |
| "refresh_token": rt, |
| "audience": f"https://{DOMAIN}/me/", |
| "scope": "create:me:connected_accounts read:me:connected_accounts", |
| }) |
| if resp.status_code != 200: |
| return JSONResponse({"error": "MRRT exchange failed", "detail": resp.json()}, 400) |
| my_token = resp.json()["access_token"] |
|
|
| |
| state = _secrets.token_urlsafe(32) |
| base_url = str(request.base_url).rstrip("/") |
| resp2 = await hc.post( |
| f"https://{DOMAIN}/me/v1/connected-accounts/connect", |
| headers={"Authorization": f"Bearer {my_token}", "Content-Type": "application/json"}, |
| json={ |
| "connection": conn_config["connection"], |
| "redirect_uri": f"{base_url}/auth/connected-accounts/callback", |
| "state": state, |
| "scopes": conn_config["scopes"], |
| }, |
| ) |
| if resp2.status_code not in (200, 201): |
| return JSONResponse({"error": "Connect initiation failed", "detail": resp2.json()}, 400) |
|
|
| data = resp2.json() |
| _connect_sessions[state] = { |
| "auth_session": data["auth_session"], |
| "my_account_token": my_token, |
| "redirect_uri": f"{base_url}/auth/connected-accounts/callback", |
| "service": service, |
| } |
|
|
| ticket = data.get("connect_params", {}).get("ticket", "") |
| connect_uri = data.get("connect_uri", "") |
| return RedirectResponse(f"{connect_uri}?ticket={ticket}") |
|
|
|
|
| async def connected_accounts_callback(request): |
| """ |
| Handle the Connected Accounts callback. |
| The connect_code comes in the URL fragment (#connect_code=...) which the |
| browser doesn't send to the server. So we serve a tiny HTML page with JS |
| that reads the fragment and POSTs it back. |
| """ |
| |
| |
| return HTMLResponse(""" |
| <!DOCTYPE html> |
| <html> |
| <head><title>Connecting OneDrive...</title> |
| <style> |
| body { font-family: system-ui; display: flex; justify-content: center; |
| align-items: center; height: 100vh; margin: 0; background: #0a0a0a; color: #e0e0e0; } |
| .card { text-align: center; padding: 2rem; border-radius: 12px; |
| background: #1a1a1a; border: 1px solid #333; max-width: 500px; } |
| .spinner { animation: spin 1s linear infinite; font-size: 2rem; } |
| @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } |
| .success { color: #4caf50; } |
| .error { color: #f44336; } |
| </style> |
| </head> |
| <body> |
| <div class="card"> |
| <div id="status"><span class="spinner">◠</span><br><br>Linking service to Token Vault...</div> |
| </div> |
| <script> |
| (async () => { |
| const hash = window.location.hash.substring(1); |
| const query = window.location.search.substring(1); |
| const all = hash + '&' + query; |
| const params = new URLSearchParams(all); |
| |
| const connectCode = params.get('connect_code') || params.get('code'); |
| const state = params.get('state'); |
| |
| if (!connectCode) { |
| document.getElementById('status').innerHTML = |
| '<span class="error">No connect_code found in callback.</span>' + |
| '<br><br>Fragment: ' + (hash || 'empty') + |
| '<br>Query: ' + (query || 'empty'); |
| return; |
| } |
| |
| try { |
| const resp = await fetch('/auth/connected-accounts/complete', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ connect_code: connectCode, state: state }), |
| }); |
| const data = await resp.json(); |
| if (data.success) { |
| const svc = data.service || 'Service'; |
| document.getElementById('status').innerHTML = |
| '<span class="success">✓ ' + svc + ' connected via Token Vault!</span>' + |
| '<br><br>Return to Amanat to start scanning.' + |
| '<br><br><a href="/" style="color:#90caf9;">Back to Amanat</a>'; |
| } else { |
| document.getElementById('status').innerHTML = |
| '<span class="error">Connection failed</span><br><br>' + |
| JSON.stringify(data, null, 2); |
| } |
| } catch (e) { |
| document.getElementById('status').innerHTML = |
| '<span class="error">Error: ' + e.message + '</span>'; |
| } |
| })(); |
| </script> |
| </body> |
| </html> |
| """) |
|
|
|
|
| async def connected_accounts_complete(request): |
| """Complete the Connected Accounts flow with the connect_code from JS.""" |
| body = await request.json() |
| connect_code = body.get("connect_code", "") |
| state = body.get("state", "") |
|
|
| |
| session = None |
| if state and state in _connect_sessions: |
| session = _connect_sessions.pop(state) |
| elif _connect_sessions: |
| |
| _, session = _connect_sessions.popitem() |
|
|
| if not session: |
| return JSONResponse({"success": False, "error": "No pending connect session found"}) |
|
|
| async with _httpx.AsyncClient() as hc: |
| resp = await hc.post( |
| f"https://{DOMAIN}/me/v1/connected-accounts/complete", |
| headers={ |
| "Authorization": f"Bearer {session['my_account_token']}", |
| "Content-Type": "application/json", |
| }, |
| json={ |
| "auth_session": session["auth_session"], |
| "connect_code": connect_code, |
| "redirect_uri": session["redirect_uri"], |
| }, |
| ) |
|
|
| svc_display = _SERVICE_DISPLAY.get(session.get("service", ""), {}).get("name", "Service") |
| if resp.status_code in (200, 201): |
| return JSONResponse({"success": True, "service": svc_display, "detail": resp.json()}) |
| else: |
| return JSONResponse({"success": False, "service": svc_display, "error": resp.json()}) |
|
|
| async def disconnect_service(request): |
| """Revoke a connected service from the user's Token Vault session.""" |
| service = request.path_params.get("service", "") |
| if not service: |
| return JSONResponse({"error": "Missing service"}, 400) |
|
|
| |
| |
| display = _SERVICE_DISPLAY.get(service, {}).get("name", service) |
| return JSONResponse({ |
| "success": True, |
| "message": f"{display} disconnected. Reconnect via /connect/{service}.", |
| "service": service, |
| }) |
|
|
|
|
| |
| chainlit_app.routes.insert(0, Route("/connect/{service}", connect_service, methods=["GET"])) |
| chainlit_app.routes.insert(1, Route("/auth/connected-accounts/callback", connected_accounts_callback, methods=["GET"])) |
| chainlit_app.routes.insert(2, Route("/auth/connected-accounts/complete", connected_accounts_complete, methods=["POST"])) |
| chainlit_app.routes.insert(3, Route("/disconnect/{service}", disconnect_service, methods=["GET"])) |
|
|
| |
| |
| DEMO_MODE = not os.environ.get("OAUTH_AUTH0_CLIENT_ID") |
| DEMO_TOOLS = os.environ.get("DEMO_TOOLS", "").lower() in ("true", "1", "yes") |
|
|
| |
|
|
|
|
| @cl.oauth_callback |
| async def oauth_callback( |
| provider_id: str, |
| token: str, |
| raw_user_data: dict[str, str], |
| default_user: User, |
| id_token: Optional[str] = None, |
| ) -> Optional[User]: |
| """Handle Auth0 OAuth callback. Store the token for Token Vault exchange.""" |
| default_user.metadata["auth0_token"] = token |
| default_user.metadata["raw_user_data"] = raw_user_data |
| |
| last_resp = getattr(Auth0OAuthProvider, "_last_token_response", {}) |
| default_user.metadata["refresh_token"] = last_resp.get("refresh_token", "") |
| |
| rt = last_resp.get("refresh_token", "") |
| if rt: |
| with open("/tmp/amanat_tokens.txt", "w") as f: |
| f.write(f"refresh_token: {rt}\n") |
| return default_user |
|
|
|
|
|
|
| @cl.set_chat_profiles |
| async def set_chat_profiles(current_user: cl.User | None = None): |
| if DEMO_TOOLS: |
| |
| return [ |
| cl.ChatProfile( |
| name="Scan & Investigate", |
| markdown_description="Live demo scanning synthetic humanitarian data from the Waqwaq scenario: displaced persons registries, GBV incident reports, biometric logs, Slack messages with leaked PII. Powered by IBM Granite 4 on watsonx.ai (same model runs locally via llama-server). Auth0 Token Vault for multi-service OAuth, CIBA with Guardian push for destructive actions. All synthetic data is [open in the GitHub repo](https://github.com/msradam/amanat/tree/main/demo-data/drive).", |
| icon="/public/icons/scan.svg", |
| starters=[ |
| cl.Starter( |
| label="Scan files for PII exposure", |
| message="Scan all files for sensitive data exposure, PII, oversharing, and policy violations.", |
| icon="/public/icons/scan.svg", |
| ), |
| cl.Starter( |
| label="Check messages for leaked data", |
| message="Search messages for any content containing beneficiary names, case numbers, or medical information in public channels.", |
| icon="/public/icons/message.svg", |
| ), |
| cl.Starter( |
| label="Check data retention compliance", |
| message="Which files have exceeded their data retention period? Flag any PII older than 12 months and special category data older than 6 months.", |
| icon="/public/icons/policy.svg", |
| ), |
| cl.Starter( |
| label="Check Outlook for leaked data", |
| message="Search Outlook for emails containing beneficiary data or case numbers sent to external recipients. If you find violations, send a data protection alert to the sender.", |
| icon="/public/icons/message.svg", |
| ), |
| ], |
| ), |
| cl.ChatProfile( |
| name="Compliance", |
| markdown_description="Generate DPIAs, check consent, ask policy questions.", |
| icon="/public/icons/policy.svg", |
| starters=[ |
| cl.Starter( |
| label="Generate a DPIA for biometric enrollment", |
| message="We're starting a biometric enrollment program that collects fingerprints and iris scans for aid distribution. Generate a DPIA for this.", |
| icon="/public/icons/policy.svg", |
| ), |
| cl.Starter( |
| label="ICRC rules on data sharing", |
| message="What does the ICRC Handbook say about sharing displaced person data with host governments? Do we need consent?", |
| icon="/public/icons/policy.svg", |
| ), |
| cl.Starter( |
| label="Check consent documentation", |
| message="Check the consent documentation status for our displaced persons registry and biometric enrollment log. Are we compliant with ICRC requirements?", |
| icon="/public/icons/policy.svg", |
| ), |
| cl.Starter( |
| label="Rules for sharing data with donors", |
| message="What are the rules for sharing displaced person data with donors like the Ambara Development Fund? What does GDPR say about this?", |
| icon="/public/icons/policy.svg", |
| ), |
| ], |
| ), |
| cl.ChatProfile( |
| name="Remediate", |
| markdown_description="Scan and fix: revoke sharing, redact PII, delete files, post alerts. Remediation triggers CIBA step-up auth via Guardian push. Actions return success in demo mode (no real files are modified).", |
| icon="/public/icons/shield.svg", |
| starters=[ |
| cl.Starter( |
| label="Lock down publicly shared files", |
| message="Find all publicly shared files containing PII and revoke their sharing links.", |
| icon="/public/icons/shield.svg", |
| ), |
| cl.Starter( |
| label="Redact PII for donor sharing", |
| message="The Ambara Development Fund is requesting our displaced persons registry for their quarterly audit. Redact all PII and upload a clean copy.", |
| icon="/public/icons/shield.svg", |
| ), |
| cl.Starter( |
| label="Alert Slack about PII leak", |
| message="Post a data protection alert to the field-updates channel warning about PII found in public messages.", |
| icon="/public/icons/message.svg", |
| ), |
| cl.Starter( |
| label="Delete expired biometric data", |
| message="The biometric enrollment log has exceeded its retention period. Download it locally and then delete it from OneDrive.", |
| icon="/public/icons/shield.svg", |
| ), |
| ], |
| ), |
| ] |
|
|
| |
| return [ |
| cl.ChatProfile( |
| name="Scan & Investigate", |
| markdown_description="Scan files, messages, and emails for data protection issues.", |
| icon="/public/icons/scan.svg", |
| starters=[ |
| cl.Starter( |
| label="Connect services", |
| message="connect services", |
| icon="/public/icons/shield.svg", |
| ), |
| cl.Starter( |
| label="Scan OneDrive for PII exposure", |
| message="I'm worried our field team has been sharing beneficiary data too openly. Can you scan our OneDrive for any files with PII that are publicly accessible?", |
| icon="/public/icons/scan.svg", |
| ), |
| cl.Starter( |
| label="Check Slack for leaked data", |
| message="Search Slack for any messages containing beneficiary names, case numbers, or medical information in public channels.", |
| icon="/public/icons/message.svg", |
| ), |
| cl.Starter( |
| label="Check data retention", |
| message="Which of our files have exceeded their data retention period? Flag any PII older than 12 months and special category data older than 6 months.", |
| icon="/public/icons/policy.svg", |
| ), |
| ], |
| ), |
| cl.ChatProfile( |
| name="Remediate", |
| markdown_description="Find risks and fix them. Revoke sharing, redact PII, download locally.", |
| icon="/public/icons/shield.svg", |
| starters=[ |
| cl.Starter( |
| label="Lock down public files", |
| message="Find all publicly shared files containing PII and revoke their sharing links.", |
| icon="/public/icons/shield.svg", |
| ), |
| cl.Starter( |
| label="Prepare file for donor sharing", |
| message="The Ambara Development Fund is requesting our displaced persons registry for their quarterly audit. Can you check what PII is in it and redact it for safe sharing?", |
| icon="/public/icons/shield.svg", |
| ), |
| cl.Starter( |
| label="Alert Slack about PII leak", |
| message="Post a data protection alert to the #field-updates channel warning the team about PII we found in their messages.", |
| icon="/public/icons/message.svg", |
| ), |
| cl.Starter( |
| label="Download sensitive files locally", |
| message="Download all sensitive files from OneDrive to local storage so we have a backup before making changes.", |
| icon="/public/icons/message.svg", |
| ), |
| ], |
| ), |
| cl.ChatProfile( |
| name="Compliance", |
| markdown_description="Generate DPIAs, check consent, ask policy questions.", |
| icon="/public/icons/policy.svg", |
| starters=[ |
| cl.Starter( |
| label="Generate a DPIA for biometric enrollment", |
| message="We're starting a biometric enrollment program at Kanbaloh that collects fingerprints and iris scans for aid distribution. Generate a DPIA for this.", |
| icon="/public/icons/policy.svg", |
| ), |
| cl.Starter( |
| label="ICRC rules on data sharing", |
| message="What does the ICRC Handbook say about sharing displaced person data with host governments? Do we need consent?", |
| icon="/public/icons/policy.svg", |
| ), |
| cl.Starter( |
| label="Check consent documentation", |
| message="Check the consent documentation status for our displaced persons registry and biometric enrollment log. Are we compliant with ICRC requirements?", |
| icon="/public/icons/policy.svg", |
| ), |
| cl.Starter( |
| label="Rules for sharing data with donors", |
| message="What are the rules for sharing displaced person data with donors like the Ambara Development Fund? What does GDPR say about this?", |
| icon="/public/icons/policy.svg", |
| ), |
| ], |
| ), |
| ] |
|
|
|
|
| @cl.on_chat_start |
| async def on_start(): |
| """Set up the session and show service connection status with quick actions.""" |
| session_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") |
| cl.user_session.set("session_id", session_id) |
| cl.user_session.set("messages", []) |
| cl.user_session.set("scan_results", []) |
| _audit_log(session_id, "session_start", {"demo_mode": DEMO_MODE}) |
|
|
| |
| |
| vault = Auth0TokenVault(demo_mode=DEMO_MODE or DEMO_TOOLS) |
|
|
| user = cl.user_session.get("user") |
| if user and not DEMO_MODE: |
| auth0_token = user.metadata.get("auth0_token", "") |
| refresh_token = user.metadata.get("refresh_token", "") |
| raw = user.metadata.get("raw_user_data", {}) |
| vault.create_session( |
| user_id=user.identifier, |
| email=user.identifier, |
| name=raw.get("name", user.identifier), |
| auth0_token=auth0_token, |
| refresh_token=refresh_token, |
| ) |
|
|
| cl.user_session.set("connected_services", []) |
| cl.user_session.set("not_connected", list(CONNECTIONS.keys())) |
| else: |
| vault.create_session(user_id="demo", email="demo", name="Demo User") |
|
|
| cl.user_session.set("vault", vault) |
|
|
|
|
| |
| _QUICK_ACTION_MESSAGES = { |
| "full_scan": ( |
| "Scan my OneDrive files for data protection issues, then search Slack for " |
| "leaked displaced person data, and check Outlook for sensitive emails sent externally." |
| ), |
| "scan_onedrive": "Scan my OneDrive files for sensitive data exposure, oversharing, and policy violations.", |
| "scan_slack": ( |
| "Search Slack channels for messages containing displaced person names, case numbers, " |
| "GPS coordinates, or medical information shared in public channels." |
| ), |
| "scan_outlook": ( |
| "Search Outlook for emails containing displaced person data that were sent to " |
| "external recipients or donor partners." |
| ), |
| "retention": ( |
| "Check which files have exceeded their data retention period. Flag PII older " |
| "than 12 months and special category data older than 6 months." |
| ), |
| "redact": ( |
| "I need to share the Cataclysm displaced registry (doc-001) with the Ambara Development Fund. " |
| "Detect all PII and create a redacted version that's safe to share." |
| ), |
| "dpia": ( |
| "Generate a Data Protection Impact Assessment for our biometric enrollment " |
| "program that collects fingerprints and iris scans for supply distribution verification." |
| ), |
| "consent": "Check the consent documentation status for doc-001 and doc-005. Are we compliant?", |
| } |
|
|
|
|
| @cl.action_callback("quick_action") |
| async def on_quick_action(action: cl.Action): |
| """Handle quick-action button clicks by injecting the corresponding message.""" |
| action_key = action.payload.get("action", "") |
| message_text = _QUICK_ACTION_MESSAGES.get(action_key, "") |
| if message_text: |
| msg = cl.Message(content=message_text, author="User") |
| await msg.send() |
| await on_message(msg) |
|
|
|
|
| @cl.on_message |
| async def on_message(message: cl.Message): |
| """Handle user messages with Strands agent + Chainlit tool call visibility.""" |
| from strands.hooks.events import BeforeToolCallEvent, AfterToolCallEvent |
|
|
| query = message.content |
| session_id = cl.user_session.get("session_id", "unknown") |
| profile = cl.user_session.get("chat_profile", "Scan") |
| remediate_mode = profile == "Remediate" |
|
|
| |
| if query.strip().lower() in ("connect services", "connect", "connect my services"): |
| if DEMO_TOOLS: |
| lines = [ |
| "**Connect your cloud services** (demo mode):\n", |
| '📁 <a href="/connect/onedrive" style="color:#14A89B;font-weight:600">Connect Microsoft</a> (OneDrive + Outlook)', |
| '💬 <a href="/connect/slack" style="color:#14A89B;font-weight:600">Connect Slack</a>', |
| "\nServices use synthetic humanitarian data in demo mode.", |
| ] |
| await cl.Message(content="\n".join(lines), author="Amanat").send() |
| else: |
| lines = [ |
| "**Connect your cloud services:**\n", |
| '📁 <a href="/connect/onedrive" style="color:#14A89B;font-weight:600">Connect Microsoft</a> (OneDrive + Outlook)', |
| '💬 <a href="/connect/slack" style="color:#14A89B;font-weight:600">Connect Slack</a>', |
| "\nAfter connecting, start a **New Chat** to begin scanning.", |
| ] |
| await cl.Message(content="\n".join(lines), author="Amanat").send() |
| return |
|
|
| |
| _QUERY_EXPANSIONS = { |
| "check all my files": "Scan all OneDrive files for sensitive data exposure, PII, oversharing, and policy violations.", |
| "audit everything": "Scan OneDrive files for PII and sharing violations, then search Slack for leaked beneficiary data, then search Outlook for sensitive emails sent externally.", |
| "is our data safe?": "Scan OneDrive for publicly shared files containing PII, then check Slack for leaked case numbers or medical data in public channels.", |
| "any problems?": "Scan OneDrive for data protection issues. Check for PII in publicly shared files, oversharing violations, and retention policy breaches.", |
| "what do you see?": "Scan OneDrive for all files and report any data governance issues. Check for PII exposure, sharing violations, or retention problems.", |
| "scan everything": "Scan OneDrive files for PII and sharing violations, then search Slack for leaked beneficiary data, then search Outlook for sensitive emails.", |
| "run a full audit": "Scan OneDrive files for PII and sharing violations, then search Slack for leaked beneficiary data, then search Outlook for sensitive emails sent externally.", |
| "scan outlook": "Search Outlook emails for messages containing beneficiary names, case numbers, or medical information. Check for PII sent to external recipients.", |
| "check outlook": "Search Outlook emails for messages containing beneficiary names, case numbers, or medical information. Check for PII sent to external recipients.", |
| "check emails": "Search Outlook emails for messages containing beneficiary names, case numbers, or medical information. Check for PII sent to external recipients.", |
| "scan slack": "Search Slack for messages containing beneficiary names, case numbers, or medical information in public channels.", |
| "check slack": "Search Slack for messages containing beneficiary names, case numbers, or medical information in public channels.", |
| } |
| query_lower = query.strip().lower().rstrip("?!.") |
| if query_lower in _QUERY_EXPANSIONS: |
| query = _QUERY_EXPANSIONS[query_lower] |
|
|
| _audit_log(session_id, "user_message", {"query": query, "profile": profile}) |
|
|
| |
| |
| vault = cl.user_session.get("vault") |
| access_token = None |
| _service_tokens: dict[str, str] = {} |
| if vault and not vault.demo_mode: |
| for svc in ("onedrive", "slack", "outlook"): |
| try: |
| token_info = vault.get_token(svc) |
| _service_tokens[svc] = token_info.access_token |
| except Exception: |
| pass |
| |
| access_token = _service_tokens.get("onedrive") or _service_tokens.get("outlook") |
|
|
| |
| if not access_token and not _service_tokens: |
| q_lower = query.lower() |
| needed = [] |
| if any(w in q_lower for w in ("onedrive", "file", "scan", "drive", "gbv", "biometric", "document")): |
| needed.append(("Microsoft (OneDrive + Outlook)", "/connect/onedrive")) |
| if any(w in q_lower for w in ("slack", "channel", "message")): |
| needed.append(("Slack", "/connect/slack")) |
| if any(w in q_lower for w in ("outlook", "email", "mail")): |
| needed.append(("Microsoft (OneDrive + Outlook)", "/connect/onedrive")) |
|
|
| if needed: |
| |
| seen = set() |
| unique = [] |
| for name, url in needed: |
| if name not in seen: |
| seen.add(name) |
| unique.append((name, url)) |
|
|
| links = " ".join( |
| f'<a href="{url}" style="display:inline-block;padding:6px 16px;background:#14A89B;color:#fff;border-radius:6px;text-decoration:none;font-weight:600">{name}</a>' |
| for name, url in unique |
| ) |
| await cl.Message( |
| content=f"You need to connect a service first to run this query.\n\n{links}\n\nAfter connecting, start a new chat and try again.", |
| author="Amanat", |
| ).send() |
| return |
|
|
| |
| if message.elements: |
| for el in message.elements: |
| if hasattr(el, "path") and el.path: |
| query += f"\n\nUploaded file at: {el.path}" |
|
|
| |
| system_prompt = _build_system_prompt(query) |
|
|
| |
| _provider_supports_tools = "openrouter" not in os.environ.get("OPENAI_API_BASE", "") |
|
|
| |
| |
| if not _provider_supports_tools and (DEMO_TOOLS or DEMO_MODE): |
| q_lower = query.lower() |
| tool_results = [] |
| if any(w in q_lower for w in ("scan", "onedrive", "file", "check", "gbv", "biometric", "audit")): |
| tool_results.append(execute_tool("scan_files", {"service": "onedrive"}, access_token=None)) |
| if any(w in q_lower for w in ("slack", "channel", "message")): |
| tool_results.append(execute_tool("search_messages", {"service": "slack", "query": "beneficiary OR case OR medical"}, access_token=None)) |
| if any(w in q_lower for w in ("outlook", "email", "mail")): |
| tool_results.append(execute_tool("search_messages", {"service": "outlook", "query": "beneficiary OR case"}, access_token=None)) |
| if any(w in q_lower for w in ("retention", "expired", "old")): |
| tool_results.append(execute_tool("retention_scan", {"service": "onedrive"}, access_token=None)) |
| if any(w in q_lower for w in ("dpia", "impact assessment")): |
| tool_results.append(execute_tool("generate_dpia", {"activity": "humanitarian data processing", "data_types": "personal_identifier,special_category_data,biometric_data", "purpose": "beneficiary assistance"}, access_token=None)) |
| if any(w in q_lower for w in ("consent", "compliant")): |
| tool_results.append(execute_tool("check_consent", {"file_id": "doc-001", "service": "onedrive"}, access_token=None)) |
|
|
| if tool_results: |
| |
| results_text = "\n\n".join( |
| r.split("\n---JSON---")[0][:2000] if "---JSON---" in r else r[:2000] |
| for r in tool_results |
| ) |
| system_prompt += f"\n\nTool results from scanning the user's services:\n\n{results_text}\n\nAnalyze these results and respond to the user's query." |
|
|
| |
| agent = create_agent(system_prompt=system_prompt, access_token=access_token, |
| service_tokens=_service_tokens, demo_tools=DEMO_TOOLS) |
|
|
| |
| task_list = cl.TaskList() |
| task_list.status = "Running..." |
| await task_list.send() |
|
|
| |
| all_scan_results = [] |
| |
| _open_steps: dict[str, cl.Step] = {} |
|
|
| |
|
|
| |
| import asyncio |
| _main_loop = asyncio.get_event_loop() |
|
|
| def _run_async(coro): |
| """Run an async coroutine from Strands' sync thread.""" |
| future = asyncio.run_coroutine_threadsafe(coro, _main_loop) |
| return future.result(timeout=130) |
|
|
| def before_tool(event: BeforeToolCallEvent): |
| """Show each tool call as a Chainlit step + confirmation gate.""" |
| tool_name = event.tool_use.get("name", "unknown") |
| tool_args = event.tool_use.get("input", {}) |
| if isinstance(tool_args, str): |
| try: |
| import json as _json |
| tool_args = _json.loads(tool_args) |
| except Exception: |
| tool_args = {} |
| tool_id = event.tool_use.get("toolUseId", "") |
|
|
| step_name = _friendly_step_name(tool_name, tool_args) |
|
|
| |
| async def _show_step(): |
| step = cl.Step(name=step_name, type="tool") |
| step.input = json.dumps(tool_args, indent=2) |
| await step.send() |
| _open_steps[tool_id] = step |
| task = cl.Task(title=step_name, status=cl.TaskStatus.RUNNING) |
| await task_list.add_task(task) |
| await task_list.send() |
|
|
| try: |
| _run_async(_show_step()) |
| except Exception: |
| pass |
|
|
| |
| if tool_name in ("delete_file", "revoke_sharing"): |
| file_id = tool_args.get("file_id", "unknown") if isinstance(tool_args, dict) else "unknown" |
| action_label = tool_name.replace("_", " ") |
|
|
| |
| approved = False |
| user = cl.user_session.get("user") |
| user_id = user.metadata.get("raw_user_data", {}).get("sub", "") if user else "" |
|
|
| if user_id and DOMAIN and _CLIENT_ID and _CLIENT_SECRET: |
| |
| async def _ciba_approve(): |
| import httpx as _hx |
| async with _hx.AsyncClient() as hc: |
| |
| resp = await hc.post( |
| f"https://{DOMAIN}/bc-authorize", |
| data={ |
| "client_id": _CLIENT_ID, |
| "client_secret": _CLIENT_SECRET, |
| "login_hint": json.dumps({"format": "iss_sub", "iss": f"https://{DOMAIN}/", "sub": user_id}), |
| "scope": "openid", |
| "binding_message": "Amanat: " + "".join(c if c.isalnum() or c in " +-_.,:#" else "" for c in f"{action_label} {file_id[:30]}"), |
| }, |
| ) |
| if resp.status_code != 200: |
| print(f"[CIBA] bc-authorize failed: {resp.status_code} {resp.text}") |
| return None |
| ciba_data = resp.json() |
| auth_req_id = ciba_data["auth_req_id"] |
| interval = ciba_data.get("interval", 5) |
| expires_in = ciba_data.get("expires_in", 300) |
|
|
| binding_msg = "Amanat: " + "".join(c if c.isalnum() or c in " +-_.,:#" else "" for c in f"{action_label} {file_id[:30]}") |
|
|
| await cl.Message( |
| content=( |
| f"**CIBA Step-Up Authentication**\n\n" |
| f"A Guardian push notification has been sent to your phone.\n\n" |
| f"**Action:** {action_label} `{file_id[:40]}`\n\n" |
| f"Approve or deny on your device to continue.\n\n" |
| f"```\n" |
| f"POST /bc-authorize\n" |
| f"auth_req_id: {auth_req_id}\n" |
| f"binding_message: {binding_msg}\n" |
| f"expires_in: {expires_in}s\n" |
| f"grant_type: urn:openid:params:grant-type:ciba\n" |
| f"user: {user_id}\n" |
| f"```" |
| ), |
| ).send() |
|
|
| |
| import asyncio as _aio |
| for attempt in range(60): |
| await _aio.sleep(interval) |
| token_resp = await hc.post( |
| f"https://{DOMAIN}/oauth/token", |
| data={ |
| "grant_type": "urn:openid:params:grant-type:ciba", |
| "client_id": _CLIENT_ID, |
| "client_secret": _CLIENT_SECRET, |
| "auth_req_id": auth_req_id, |
| }, |
| ) |
| result = token_resp.json() |
| if "access_token" in result: |
| await cl.Message( |
| content=( |
| f"**Approved via Guardian.** Proceeding with action.\n\n" |
| f"```\n" |
| f"CIBA token received\n" |
| f"token_type: {result.get('token_type', 'Bearer')}\n" |
| f"scope: {result.get('scope', 'openid')}\n" |
| f"expires_in: {result.get('expires_in', '')}s\n" |
| f"```" |
| ), |
| ).send() |
| return True |
| if result.get("error") == "authorization_pending": |
| continue |
| if result.get("error") == "expired_token": |
| await cl.Message(content="**CIBA request expired.** Action cancelled.").send() |
| return False |
| if result.get("error") == "access_denied": |
| await cl.Message(content="**Denied via Guardian.** Action cancelled.").send() |
| return False |
| await cl.Message(content="**CIBA request timed out.** Action cancelled.").send() |
| return False |
|
|
| try: |
| ciba_result = _run_async(_ciba_approve()) |
| if ciba_result is not None: |
| approved = ciba_result |
| else: |
| raise Exception("CIBA unavailable") |
| except Exception: |
| |
| async def _ask(): |
| res = await cl.AskActionMessage( |
| content=f"**Confirm:** {action_label} `{file_id[:40]}`?", |
| actions=[ |
| cl.Action(name="confirm", payload={"value": "yes"}, label="Approve"), |
| cl.Action(name="cancel", payload={"value": "no"}, label="Deny"), |
| ], |
| timeout=120, |
| ).send() |
| return res and res.get("payload", {}).get("value") == "yes" |
| try: |
| approved = _run_async(_ask()) |
| except Exception: |
| approved = True |
| else: |
| |
| async def _ask_fallback(): |
| res = await cl.AskActionMessage( |
| content=f"**Confirm:** {action_label} `{file_id[:40]}`?", |
| actions=[ |
| cl.Action(name="confirm", payload={"value": "yes"}, label="Approve"), |
| cl.Action(name="cancel", payload={"value": "no"}, label="Deny"), |
| ], |
| timeout=120, |
| ).send() |
| return res and res.get("payload", {}).get("value") == "yes" |
| try: |
| approved = _run_async(_ask_fallback()) |
| except Exception: |
| approved = True |
|
|
| if not approved: |
| _audit_log(session_id, "remediation_denied", {"tool": tool_name, "file_id": file_id}) |
| event.tool_use["input"] = {} |
| return |
|
|
| _audit_log(session_id, "remediation_approved", {"tool": tool_name, "file_id": file_id, "method": "ciba" if user_id else "ui"}) |
|
|
| |
| if tool_name == "delete_file" and access_token: |
| for fid in file_id.split(","): |
| fid = fid.strip() |
| if fid: |
| service = tool_args.get("service", "onedrive") |
| execute_tool("download_file", {"file_id": fid, "service": service}, |
| access_token=access_token) |
|
|
| _audit_log(session_id, "tool_call_start", { |
| "tool": tool_name, "args": tool_args, |
| }) |
|
|
| def after_tool(event: AfterToolCallEvent): |
| """Close Chainlit step, extract scan results for charts.""" |
| tool_name = event.tool_use.get("name", "unknown") |
| tool_id = event.tool_use.get("toolUseId", "") |
| result = event.result |
|
|
| |
| result_text = "" |
| if isinstance(result, str): |
| result_text = result |
| elif isinstance(result, dict): |
| content = result.get("content", []) |
| if isinstance(content, str): |
| result_text = content |
| elif isinstance(content, list): |
| for block in content: |
| if isinstance(block, dict) and "text" in block: |
| result_text += block["text"] |
| elif isinstance(block, str): |
| result_text += block |
|
|
| |
| if tool_name == "scan_files" and "---JSON---" in result_text: |
| try: |
| json_part = result_text.split("---JSON---", 1)[1].strip() |
| result_data = json.loads(json_part) |
| if "results" in result_data: |
| all_scan_results.extend(result_data["results"]) |
| except (json.JSONDecodeError, IndexError): |
| pass |
|
|
| |
| async def _close_step(): |
| step = _open_steps.pop(tool_id, None) |
| if step: |
| step.output = _summarize_result(tool_name, result_text) |
| await step.update() |
| if task_list.tasks: |
| task_list.tasks[-1].status = cl.TaskStatus.DONE |
| await task_list.send() |
|
|
| try: |
| _run_async(_close_step()) |
| except Exception: |
| pass |
|
|
| _audit_log(session_id, "tool_call_end", { |
| "tool": tool_name, |
| "result_len": len(result_text), |
| "result_preview": result_text[:300], |
| }) |
|
|
| |
| agent.hooks.add_callback(BeforeToolCallEvent, before_tool) |
| agent.hooks.add_callback(AfterToolCallEvent, after_tool) |
|
|
| |
| try: |
| result = await cl.make_async(lambda: agent(query))() |
|
|
| |
| final_content = "Analysis complete." |
| msg = result.message |
| if msg: |
| if isinstance(msg, str): |
| final_content = msg |
| elif isinstance(msg, dict) and msg.get("content"): |
| content = msg["content"] |
| if isinstance(content, str): |
| final_content = content |
| elif isinstance(content, list): |
| for block in content: |
| if isinstance(block, dict) and "text" in block: |
| final_content = block["text"] |
| break |
| elif isinstance(block, str): |
| final_content = block |
| break |
|
|
| except Exception as e: |
| final_content = f"Error: {e}" |
| print(f"[Amanat] Error: {e}") |
| import traceback |
| traceback.print_exc() |
|
|
| |
| task_list.status = "Done" |
| await task_list.send() |
|
|
| |
| elements = [] |
| if all_scan_results: |
| chart = _build_risk_chart(all_scan_results) |
| if chart: |
| elements.append(chart) |
| df_el = _build_results_table(all_scan_results) |
| if df_el: |
| elements.append(df_el) |
|
|
| _audit_log(session_id, "agent_response", { |
| "response": final_content[:2000], |
| "scan_results_count": len(all_scan_results), |
| }) |
|
|
| |
| actions = [] |
| risky = [f for f in all_scan_results |
| if f.get("risk_level") == "critical" |
| and f.get("sharing") in ("anyone_with_link", "org_wide")] |
| if not remediate_mode: |
| for f in risky[:5]: |
| actions.append(cl.Action( |
| name="suggest_remediate", |
| payload={"file_id": f["file_id"], "name": f["name"]}, |
| label=f"Fix: {f['name'][:30]}", |
| )) |
| else: |
| for f in risky[:5]: |
| actions.extend([ |
| cl.Action( |
| name="action_revoke", |
| payload={"file_id": f["file_id"], "name": f["name"]}, |
| label=f"Revoke: {f['name'][:25]}", |
| ), |
| cl.Action( |
| name="action_download", |
| payload={"file_id": f["file_id"], "name": f["name"]}, |
| label=f"Download: {f['name'][:25]}", |
| ), |
| ]) |
|
|
| await cl.Message( |
| content=final_content, |
| author="Amanat", |
| elements=elements, |
| actions=actions, |
| ).send() |
|
|
| cl.user_session.set("scan_results", all_scan_results) |
|
|
|
|
| |
|
|
| @cl.action_callback("connect_service_action") |
| async def on_connect_service(action: cl.Action): |
| """Handle connect service button click — redirect to Token Vault OAuth flow.""" |
| service = action.payload["service"] |
| display = _SERVICE_DISPLAY.get(service, {}) |
| name = display.get("name", service) |
| await cl.Message( |
| content=f"{display.get('icon', '•')} Opening **{name}** connection flow... [Click here if not redirected](/connect/{service})", |
| author="Amanat", |
| ).send() |
|
|
|
|
| @cl.action_callback("disconnect_service") |
| async def on_disconnect(action: cl.Action): |
| """Handle disconnect service button click.""" |
| service = action.payload["service"] |
| display = _SERVICE_DISPLAY.get(service, {}) |
| name = display.get("name", service) |
|
|
| vault = cl.user_session.get("vault") |
| if vault: |
| vault.revoke_service(service) |
|
|
| await cl.Message( |
| content=f"{display.get('icon', '•')} **{name}** disconnected. [Reconnect](/connect/{service})", |
| author="Amanat", |
| ).send() |
|
|
|
|
| @cl.action_callback("action_revoke") |
| async def on_revoke(action: cl.Action): |
| """Handle revoke sharing button click.""" |
| file_id = action.payload["file_id"] |
| name = action.payload["name"] |
|
|
| res = await cl.AskActionMessage( |
| content=f"**Confirm:** Revoke all public/link sharing on **{name}**?", |
| actions=[ |
| cl.Action(name="confirm", payload={"value": "yes"}, label="Yes, revoke"), |
| cl.Action(name="cancel", payload={"value": "no"}, label="Cancel"), |
| ], |
| timeout=120, |
| ).send() |
|
|
| if not res or res.get("payload", {}).get("value") != "yes": |
| await cl.Message(content=f"Cancelled revoking sharing on {name}.", author="Amanat").send() |
| return |
|
|
| vault = cl.user_session.get("vault") |
| access_token = None |
| if vault and not vault.demo_mode: |
| try: |
| token_info = vault.get_token("onedrive") |
| access_token = token_info.access_token |
| except Exception: |
| pass |
|
|
| async with cl.Step(name=f"Revoking sharing on {name}", type="tool") as step: |
| result = execute_tool("revoke_sharing", {"file_id": file_id, "service": "onedrive"}, access_token=access_token) |
| step.output = result |
|
|
| try: |
| data = json.loads(result) |
| msg = data.get("message", result) |
| except json.JSONDecodeError: |
| msg = result |
|
|
| await cl.Message(content=f"**{name}**: {msg}", author="Amanat").send() |
|
|
|
|
| @cl.action_callback("action_download") |
| async def on_download(action: cl.Action): |
| """Handle download file button click.""" |
| file_id = action.payload["file_id"] |
| name = action.payload["name"] |
|
|
| vault = cl.user_session.get("vault") |
| access_token = None |
| if vault and not vault.demo_mode: |
| try: |
| token_info = vault.get_token("onedrive") |
| access_token = token_info.access_token |
| except Exception: |
| pass |
|
|
| async with cl.Step(name=f"Downloading {name}", type="tool") as step: |
| result = execute_tool("download_file", {"file_id": file_id, "service": "onedrive"}, access_token=access_token) |
| step.output = result |
|
|
| try: |
| data = json.loads(result) |
| msg = data.get("message", result) |
| except json.JSONDecodeError: |
| msg = result |
|
|
| await cl.Message(content=f"**{name}**: {msg}", author="Amanat").send() |
|
|
|
|
| @cl.action_callback("suggest_remediate") |
| async def on_suggest_remediate(action: cl.Action): |
| """Nudge user to switch to Remediate profile.""" |
| name = action.payload["name"] |
| await cl.Message( |
| content=f"To fix **{name}**, start a new chat in **Remediate** mode (use the profile switcher at the top).", |
| author="Amanat", |
| ).send() |
|
|
|
|
| |
|
|
| @cl.on_chat_end |
| async def on_end(): |
| """Clear sensitive data from the session when the chat ends.""" |
| cl.user_session.set("scan_results", []) |
| cl.user_session.set("messages", []) |
| vault = cl.user_session.get("vault") |
| if vault: |
| vault._session = None |
|
|
|
|
| |
|
|
| def _build_risk_chart(scan_results: list[dict]) -> cl.Plotly | None: |
| """Build a Plotly risk summary chart from scan results.""" |
| if not scan_results: |
| return None |
|
|
| names = [r.get("name", "?")[:25] for r in scan_results] |
| risk_levels = [r.get("risk_level", "info") for r in scan_results] |
| sharing = [r.get("sharing", "unknown").replace("_", " ") for r in scan_results] |
|
|
| |
| colors = [] |
| for r in risk_levels: |
| if r == "critical": |
| colors.append("#ef4444") |
| elif r == "warning": |
| colors.append("#eab308") |
| else: |
| colors.append("#22c55e") |
|
|
| |
| risk_score = {"critical": 3, "warning": 2, "info": 1} |
| scores = [risk_score.get(r, 0) for r in risk_levels] |
|
|
| fig = go.Figure() |
| fig.add_trace(go.Bar( |
| x=names, |
| y=scores, |
| marker_color=colors, |
| text=[f"{r}<br>{s}" for r, s in zip(risk_levels, sharing)], |
| textposition="auto", |
| hovertemplate="<b>%{x}</b><br>Risk: %{text}<extra></extra>", |
| )) |
|
|
| fig.update_layout( |
| title=dict(text="Risk Summary", font=dict(size=16)), |
| yaxis=dict( |
| tickvals=[1, 2, 3], |
| ticktext=["Info", "Warning", "Critical"], |
| title="Risk Level", |
| ), |
| xaxis=dict(title="", tickangle=-30), |
| plot_bgcolor="rgba(0,0,0,0)", |
| paper_bgcolor="rgba(0,0,0,0)", |
| font=dict(color="#e8edf2"), |
| height=350, |
| margin=dict(b=100, t=50), |
| showlegend=False, |
| ) |
|
|
| return cl.Plotly(name="risk_summary", figure=fig, display="inline", size="large") |
|
|
|
|
| def _build_results_table(scan_results: list[dict]) -> cl.Dataframe | None: |
| """Build a dataframe element from scan results.""" |
| if not scan_results: |
| return None |
|
|
| import pandas as pd |
|
|
| rows = [] |
| for r in scan_results: |
| rows.append({ |
| "File": r.get("name", "?"), |
| "Risk": r.get("risk_level", "?").upper(), |
| "Sharing": r.get("sharing", "?").replace("_", " "), |
| "PII": "Yes" if r.get("pii_detected") else "No", |
| "Categories": ", ".join(r.get("pii_categories", [])), |
| "Owner": r.get("owner", "?"), |
| }) |
|
|
| df = pd.DataFrame(rows) |
| return cl.Dataframe(name="scan_results", data=df, display="inline", size="large") |
|
|
|
|
| |
|
|
| def _friendly_step_name(fn_name: str, args: dict) -> str: |
| """Human-readable step names for the UI.""" |
| labels = { |
| "scan_files": f"Scanning {args.get('service', 'files')}", |
| "check_sharing": f"Checking sharing on {args.get('file_id', 'file')}", |
| "detect_pii": f"Detecting PII in {args.get('file_id', 'file')}", |
| "search_messages": f"Searching {args.get('service', 'messages')} for \"{args.get('query', '')}\"", |
| "revoke_sharing": f"Revoking public sharing on {args.get('file_id', 'file')}", |
| "download_file": f"Downloading {args.get('file_id', 'file')} locally", |
| "delete_file": f"Deleting {args.get('file_id', 'file')} from cloud", |
| "redact_file": f"Redacting PII from {args.get('file_id', 'file')}", |
| "retention_scan": f"Scanning for retention violations", |
| "generate_dpia": f"Generating DPIA for {args.get('activity', 'activity')[:40]}", |
| "check_consent": f"Checking consent for {args.get('file_id', 'file')}", |
| } |
| return labels.get(fn_name, fn_name) |
|
|
|
|
| def _summarize_result(fn_name: str, result: str) -> str: |
| """Create a concise summary of tool results for the step display.""" |
| try: |
| data = json.loads(result) |
| except json.JSONDecodeError: |
| return result[:500] |
|
|
| if fn_name == "scan_files": |
| total = data.get("files_scanned", 0) |
| with_pii = data.get("files_with_pii", 0) |
| files = data.get("results", []) |
| lines = [f"Scanned **{total}** files, **{with_pii}** contain PII\n"] |
| for f in files: |
| risk = f.get("risk_level", "unknown") |
| icon = {"critical": "\U0001f534", "warning": "\U0001f7e1", "info": "\U0001f7e2"}.get(risk, "\u26aa") |
| sharing = f.get("sharing", "unknown").replace("_", " ") |
| lines.append(f"{icon} **{f['name']}** — {risk} risk, shared: {sharing}") |
| return "\n".join(lines) |
|
|
| elif fn_name == "check_sharing": |
| scope = data.get("sharing_scope", "unknown").replace("_", " ") |
| risk = data.get("sharing_risk", "unknown") |
| issue = data.get("issue", "No issues detected") |
| return f"**Sharing:** {scope} ({risk} risk)\n\n{issue}" |
|
|
| elif fn_name == "detect_pii": |
| findings = data.get("pii_findings", []) |
| if not findings: |
| return "No PII detected" |
| lines = [f"Found **{len(findings)}** types of sensitive data:\n"] |
| for f in findings: |
| lines.append(f"- **{f['type']}**: {f['count']} instances ({f['severity']})") |
| return "\n".join(lines) |
|
|
| elif fn_name == "search_messages": |
| msgs = data.get("results", []) |
| if not msgs: |
| return "No sensitive messages found" |
| lines = [f"Found **{len(msgs)}** messages with sensitive content:\n"] |
| for m in msgs: |
| channel = m.get("channel", m.get("subject", "unknown")) |
| pii = ", ".join(m.get("pii_types", [])) |
| lines.append(f"- **{channel}**: {pii}") |
| return "\n".join(lines) |
|
|
| elif fn_name in ("revoke_sharing", "download_file", "delete_file"): |
| status = data.get("status", "unknown") |
| message = data.get("message", "") |
| icon = "\u2705" if status == "success" else "\u274c" |
| return f"{icon} {message}" |
|
|
| return json.dumps(data, indent=2)[:500] |
|
|