Bezpieczeństwo¶
Ta strona jest tym, co przegląd bezpieczeństwa w kształcie HIPAA albo SOC 2 dostaje do ręki: granice zaufania, jakie dane opuszczają wdrożenie i do kogo, co jest gdzie szyfrowane, oraz macierz kontroli, która dla każdej kontroli nazywa mechanizm w tym kodzie, który ją realizuje, i test, który trzyma ją w mocy.
Opisuje to, co jest, a nie to, co byłoby miłe. Wiersz bez mechanizmu mówi to
wprost i linkuje issue, które by go zbudowało. Jak zgłosić podatność i jaka jest
produkcyjna lista kontrolna hardeningu — zobacz SECURITY.md
w korzeniu repozytorium; ta strona to cała reszta, w jednej kopii.
Dwie sąsiednie strony odpowiadają na pytania, które przegląd zadaje zaraz potem, i nie są tu powtarzane: Ochrona danych — gdzie leżą dane osobowe, co faktycznie obejmuje usunięcie i które luki są wciąż otwarte, oraz Licencje — każdy komponent trzeciej strony, który wiozą obrazy.
Model zagrożeń¶
Platforma jest self-hostowana i wielotenantowa. Założeniem projektowym jest, że infrastruktura operatora jest zaufana, a każde żądanie do niej nie jest — więc granicami, które mają znaczenie, są te, które żądanie przekracza w drodze do danych.
| Granica | Co ją przekracza | Zaufana po drugiej stronie? |
|---|---|---|
| Przeglądarka → BFF (route handlery Next.js) | Ciasteczko sesji, nagłówek organizacji, dane z formularza | Nie — ale BFF nie weryfikuje ciasteczka: czyta httpOnly agenticos_access_token i przekazuje go jako nagłówek bearer (frontend/src/lib/platform-proxy.ts). To granica przekazywania poświadczenia; weryfikacja jest zadaniem API |
| BFF → API (FastAPI) | JWT związany z sesją w bazie, nagłówek X-Organization-Id |
Nie — token jest weryfikowany przy każdym żądaniu, sesja sprawdzana pod kątem unieważnienia, a organizacja rozstrzygana z tokena |
| API → PostgreSQL / Redis | Zapytania i odczyty z cache, po TLS, gdy jest skonfigurowany | Tak — magazyn należy do operatora; co chroni w spoczynku, jest pod „Co jest gdzie szyfrowane” |
| API / worker → providerzy modeli, kanały, serwery MCP, dostawcy wyszukiwania, Logfire | Prompty, wywołania narzędzi, zapytania, odpowiedzi, trace'y | Nie — to strony trzecie; co do nich trafia, jest decyzją per agent, z wyjątkiem tracingu na poziomie całego wdrożenia (poniżej) |
| Przeglądarka ↔ provider realtime (rozmowa głosowa przez WebRTC) | Dźwięk z mikrofonu rozmówcy i wypowiedziana odpowiedź agenta | Nie — przeglądarka nie trzyma żadnego poświadczenia providera: serwer odpowiada na jej ofertę SDP, oferta otwierająca kanał danych jest odrzucana, a sekret klienta nigdy nie jest wydawany. Narzędzia, zatwierdzenia i transkrypcje działają na połączeniu sideband serwera |
| Worker → konektory (Google Drive, S3, …) | Poświadczenia odpieczętowane z vaultu, pobrane dokumenty | Nie — poświadczenie konektora to sekret w vault wskazywany po id |
| Wizualizacja albo aplikacja → konsola | Prośba o otwarcie linku albo o wysłanie wiadomości follow-up w imieniu osoby | Nie — strona to skrypt napisany przez model, który mógł przeczytać wrogie treści. Link otwiera się, a follow-up jest wysyłany, dopiero gdy człowiek go przeczyta i kliknie, we własnym DOM konsoli; follow-up, za którym nie stoi interakcja użytkownika, jest odrzucany |
Worker → publiczne strony internetowe (konektor web) |
Żądania GET o strony, sitemapy i robots.txt; bez poświadczenia | Nie — początkowy adres URL wpisuje tenant, a każdy kolejny link wybiera strona, więc każde żądanie i przekierowanie przechodzi sprawdzenie SSRF, jest wysyłane na sprawdzony adres (app/core/pinned_http.py) i pozostaje na jednym hoście |
Władza wewnątrz tenanta nigdy nie jest nazwą roli na route'cie: to wiersz
członkostwa plus katalog uprawnień (app/core/permissions.py), rozstrzygany per
zasób. Dwóch wywołujących z tą samą rolą może sięgnąć do różnych wierszy, bo
grant na jednym zasobie poszerza to, na co rola pozwala, nie awansując członka.
Co opuszcza wdrożenie¶
Nic nie dzwoni do domu. Każde wywołanie na zewnątrz jest tym, które wdrożenie skonfigurowało, i każde jest granicą, o którą przegląd u klienta zapyta.
| Do | Co | Kiedy |
|---|---|---|
| Skonfigurowany provider modelu | Prompt, wyjście modelu, argumenty i wyniki narzędzi | Każdy run — chyba że model działa na własnej infrastrukturze operatora, wtedy nic nie wychodzi |
| Skonfigurowany kanał (Slack, Teams, Telegram, Mattermost) | Wygenerowane odpowiedzi agenta — tekst, obrazy i załączniki | Zawsze, gdy agent jest wystawiony przez ten kanał; każde send_message publikuje u providera (app/services/channels/) |
| Logfire | Trace'y, które niosą prompty i wyjścia, chyba że agent mówi inaczej | Dwie niezależne ścieżki. Token observability per agent trace'uje tego agenta, a jego tryb content decyduje, ile niesie span - none sprowadza go do czasu, tokenów, kosztu i nazw narzędzi (#1413). LOGFIRE_TOKEN na poziomie wdrożenia instrumentuje każdy run, tak samo w API jak w workerze (app/core/logfire_setup.py), więc przy nim ustawionym wychodzi treść każdego agenta, który nie poprosił o none; agent, który poprosił, jest przypięty do instrumentacji bez treści również na tym tracerze (suppress_content), więc tryb trzyma na obu ścieżkach, a specjalista tego agenta go dziedziczy - napisany inline albo wymyślony w trakcie runu. Jedna luka, której nie obejmuje: nieudane podpięcie, które jest logowane i zostawione. Żadna ze ścieżek nie jest domyślnie włączona. Stanu pośredniego z filtrem świadomie nie ma - częściowo wyczyszczony eksport to gwarancja, której nikt nie zaudytuje (#1616) |
| Serwery MCP | Wywołania narzędzi i ich argumenty | Tylko dla narzędzi, do których agent jest podpięty |
| Dostawca web search (Tavily, DuckDuckGo) | Zapytanie wyszukiwania | Tylko gdy przyznana jest capability wyszukiwania |
| Provider embeddingów | Tekst dokumentu, przy ingest | Tylko dla bazy wiedzy, której provider jest zdalny |
Serwer kafelków map wskazany przez VISUAL_MAP_TILES |
Z tego deploymentu, nigdy ze strony: współrzędne każdego kafelka pokazywanego na mapie, którego nie ma jeszcze w pamięci podręcznej | Tylko gdy ustawienie jest włączone i ktoś otwiera mapę. Strona wysyła deploymentowi trzy liczby, każdą sprawdzoną względem przybliżenia; adres i klucz serwera zostają na serwerze (Aplikacje) |
| Provider głosu realtime (OpenAI, Azure OpenAI, Google, xAI) | W trakcie rozmowy głosowej: dźwięk rozmówcy, instrukcje agenta, definicje i wyniki narzędzi oraz wcześniejsze tury rozmowy jako tekst, gdy rozmowa się zaczyna albo łączy ponownie | Tylko dla agenta, którego sekcja Głos nazywa tego providera, i tylko póki rozmowa jest otwarta. Dźwięk nie jest tu przechowywany; to, co zatrzymuje provider, reguluje twoja umowa z nim |
| Provider speech-to-text | Nagranie: notatka głosowa z kanału, dyktowanie w /chat albo nagranie odwiedzającego na hostowanej stronie lub w widgecie ustawionych na server |
Zawsze, gdy któreś jest transkrybowane. Dyktowanie odwiedzającego jest anonimowe i poza każdym budżetem modelu, więc ogranicza je rate limit linku razy najdłuższe dyktowanie. Domyślnie dyktowanie to surowy PCM, którego próbki są liczone, a nagranie ponad limit odrzucane przed wysłaniem, więc to ograniczenie jest dokładne; wdrożenie, które przyjmuje skompresowany dźwięk, odczytuje zamiast tego długość z nagłówka pliku, który ręcznie zbudowany plik może zaniżyć - kanały rozpisują najgorszy przypadek w obu wariantach. Przy browser przeglądarka odwiedzającego wysyła zamiast tego dźwięk do własnego dostawcy i nic nie przechodzi tędy |
Co jest gdzie szyfrowane¶
Jest jeden mechanizm szyfrowania na poziomie aplikacji i celowo jest jedyny:
vault (app/core/vault.py). Każde poświadczenie konektora i API w spoczynku
jest zapieczętowane w kopercie per właściciel, której klucz opakowujący jest
wyprowadzany przez HKDF z organizacji (albo użytkownika), do której należy — więc
szyfrogram skopiowany do wiersza innej organizacji nie da się odpieczętować.
Klucz główny jest rotowalny bez ponownego szyfrowania ładunków.
Nie wszystko, co platforma przechowuje, jest poświadczeniem w vault, i mówimy to wprost, bo przegląd i tak to znajdzie:
- Krótkożyjące tokeny bearer — zaproszenia do organizacji
(
OrganizationInvitation.token), żądania połączenia kanału (ChannelLinkRequest.token) i linki do udostępnionych konwersacji (ConversationShare.share_token) — to losowe kolumnyString(64)wyszukiwane po równości, nie pieczętowane w vault. Kto ma wartość, ten może jej użyć, więc chroni je wygaśnięcie i jednorazowość, a nie szyfrowanie. Wyjątkiem są refresh tokeny sesji, haszowane w spoczynku (sessions.refresh_token_hash). - Wgrane pliki i pliki z czatu leżą tam, gdzie umieści je
FILE_STORAGE_BACKEND(app/services/file_storage.py). Przy domyślnymlocaljest to system plików kontenera API otwartym tekstem, chroniony wyłącznie szyfrowaniem wolumenu. Przys3są obiektami w buckecie, który nazywa wdrożenie, a o co prosi każdy zapis, rozstrzygaFILE_STORAGE_S3_ENCRYPTION:sse-s3domyślnie albosse-kmskluczem kontrolowanym przez klienta — albonone, które nie wysyła żadnego nagłówka szyfrowania i istnieje dla magazynu zgodnego z S3 bez KMS za nim. Przynoneobiekty są zaszyfrowane dokładnie na tyle, na ile zrobi to ten magazyn, i ani trochę bardziej.agenticos cmd doctorwypisuje, na którym backendzie stoi działające wdrożenie. - Treści wiadomości,
rag_documentsi ich wektory oraz workspace'y sandboksa są przechowywane jako kolumny z tekstem jawnym, wiersze pgvector i pliki workspace'u. Vault pieczętuje poświadczenia, nie treść; ochrona tych rzeczy w spoczynku jest na poziomie dysku.
Backend S3 jest wyborem dokonywanym w czasie wdrożenia i nie migruje tego, co trzyma już lokalny; ustawienia są w konfiguracji, a uzasadnienie w przetwarzaniu plików.
Co jest trzymane o jednej osobie i co się z tym dzieje¶
RODO art. 15 pyta, co o kimś trzymasz, a art. 17 każe to usunąć. Na oba odpowiada ta tabela, i to celowo: inwentarz, który wymienia tabelę przy eksporcie i zapomina o niej przy usuwaniu, jest gorszy niż żaden, bo czyta się jak kompletny.
Linia, którą rysuje, to o kimś kontra stworzone przez kogoś. To, co jest o osobie, idzie razem z nią; to, co stworzyła na rzecz organizacji — agent, na którym pracuje zespół, baza wiedzy, zapisane poświadczenie — jest przekazywane dalej, bo usunięcie konta kolegi nie może usunąć agenta, od którego zależy zespół.
| Tabela | Przy usunięciu | W eksporcie | Dlaczego |
|---|---|---|---|
users |
Usuwany | Profil, bez hasha hasła | Samo konto |
conversations, messages, tool_calls |
Kaskada, poza czatami projektu, które zostają z zespołem | Wątki, które zaczęli, z każdą turą; w czacie projektu, gdziekolwiek pisali, tylko ich tury i odpowiedzi na nie | Ich własne, a transkrypt bez co drugiej tury nie odpowiada na nic. Czat projektu należy do kilku osób, a tury pozostałych członków są ich |
chat_files |
Kaskada; bajty odpinane po commicie | Nie wypisywane | Wiersz kaskaduje od wiadomości, a plik na dysku nie, więc ścieżki są zbierane przed usunięciem i odpinane po jego commicie — inaczej bajty przeżywają żądanie, które kazało im zniknąć: nieosiągalne dla niczego i nieusuwane przez nikogo. Transkrypt w eksporcie nazywa załącznik; bajty są do pobrania dopóki konto istnieje, a nie pakowane do środka |
message_ratings |
Kaskada | Tak | Wyrażona opinia |
sessions |
Kaskada | Urządzenie, adres i czasy — nigdy poświadczenie, bo jest hashem | Gdzie się logowali |
api_keys, oauth_grants |
Kaskada, razem z kodami i tokenami, na które wymieniono zgodę | Klucze, które wydali, i klienci, którym pozwolili działać w ich imieniu: nazwa, prefiks, uprawnienia i daty — nigdy klucz, hash ani token | Poświadczenie działające w ich imieniu to ich dane; własne klucze asystenta wydawane przez platformę na jeden przebieg nie są wymieniane |
conversation_favourites, dashboard_layouts, dashboard_presets, user_slash_commands |
Kaskada | Układy i skróty | Ustawienia osobiste, dla nikogo innego bez znaczenia |
agent_memory_files (owner_key = person:<id>) |
Czyszczone jawnie, pod blokadą, którą bierze też równoległy zapis | Tak | Klucz jako łańcuch znaków bez klucza obcego: nic nie kaskadowało, więc każda notatka przeżywała konto — a run zapisujący notatkę w trakcie usuwania odtworzyłby ją |
agent_workspaces (scope = user) |
Czyszczone jawnie | Nie | owner_ref jest łańcuchem znaków z tego samego powodu co owner_key, więc nie podąża za nim żadna kaskada, a workspace oparty o stan trzyma same pliki |
organization_members |
Kaskada | Do których organizacji, jako kto, od kiedy | Wiersz wskazujący wprost na tę osobę, widoczny już dla administratora |
channel_identities |
Czyszczone jawnie | Tak | SET NULL zostawiał wiersz z id Slacka, nazwą i nazwą wyświetlaną osoby, której już nie ma, powiązany z nikim |
agent_runs |
SET NULL — zachowywane |
Runy, które uruchomili, i ile kosztowały | Wydatek jest zapisem organizacji; anonimowy run wciąż liczy się do miesiąca |
agents, knowledge_bases, skills, contexts, agent_triggers, agent_environments, agent_exposures, local_services |
SET NULL — zachowywane |
Nie | Stworzone dla organizacji. Usunięcie zabrałoby pracę zespołu razem z osobą |
organization_secrets |
SET NULL, a sekret prywatny awansuje do organizacji |
Nie | Poświadczenie, na którym działa organizacja. Awans jest tym, co nie pozwala bezpańskiemu sekretowi zablokować usunięcia |
organizations |
Org osobista usuwana; współdzielona, którą stworzyli, przechodzi na innego właściciela | Nie | Nikt nie może zostać z organizacją bez właściciela |
resource_grants |
Kaskada po obdarowanym; SET NULL po nadającym |
Nie | Uprawnienie dla nich jest ich; uprawnienie, które nadali, jest zapisem organizacji, kto co może |
app_admin_audit_logs |
Zachowywane, id aktora zostaje | Nie | Zapis organizacji o tym, co się w niej działo. Wpis nazywający usunięte konto jest uczciwy; wpis z wyciętym aktorem jest gorszy niż bezużyteczny, a osoba nie może go zabrać ze sobą |
embed_visitors |
Nietykane | Nie | Odwiedzający to klucz przeglądarki, nigdy konto — nie ma tu nic o osobie z loginem |
Czego usunięcie nie dosięga — i mówią to dokumenty, a nie wdrożenie po fakcie
Skonfigurowany zewnętrzny magazyn pamięci. mem0 trzyma to, co agent
zapamiętał, w cudzym systemie, a to wdrożenie może poprosić o zapomnienie
tylko dopóki ma poświadczenie organizacji. MemoryService.forget_person
robi to dla osoby, która wciąż jest członkiem; notatki usuniętego konta
w zewnętrznym magazynie należą do operatora.
Kopie zapasowe. Usunięcie kasuje wiersze z żywej bazy. Harmonogram kopii, który uruchamia wdrożenie, trzyma je do swojej rotacji i żadne usunięcie na poziomie aplikacji tego nie zmienia.
Własna retencja providera modelu. To, co zostało wysłane, by odpowiedzieć na turę, podlega polityce providera — opisują to modele, a to wdrożenie tego nie kontroluje.
Samoobsługa i to, co dokłada administrator¶
Osoba nie potrzebuje administratora w zwykłych przypadkach.
GET /me/data/export to cała powyższa tabela w jednym dokumencie JSON, z limitem
na godzinę i wpisem w audycie — również wtedy, gdy ktoś eksportuje samego siebie,
bo eksport ma kształt wycieku, gdy wywołujący nie jest tym, za kogo się podaje.
Dokument powstaje i serializuje się w całości, więc jest ograniczony:
PERSONAL_DATA_EXPORT_MAX_CHARS (domyślnie 16 milionów znaków) to tyle tekstu
rozmów, ile jeden dokument może unieść, a konto trzymające więcej dostaje odmowę
z obiema liczbami zamiast po cichu niepełnego dokumentu. Wytworzenie takiego
jest zadaniem operatora, wprost z bazy.
DELETE /conversations/{id} usuwa jeden wątek, jego tury i pliki, które z nimi
przyszły, sprawdzone wobec własności wywołującego (FA-015).
Administrator dokłada dwie rzeczy i obie są audytowane:
GET /admin/users/{id}/export, który wymaga powodu — czytanie całej historii
rozmów kolegi jest uzasadnione jakieś dwa razy w roku i za każdym razem poważne —
oraz DELETE /admin/users/{id}, gdzie powód jest opcjonalny, bo offboarding
zwykle nie ma czego tłumaczyć.
Macierz kontroli¶
Jeden wiersz na kontrolę, mechanizm, który ją realizuje, i test, który trzyma ją w mocy. Ujęte względem zabezpieczeń technicznych HIPAA §164.312 i SOC 2 CC6–CC8.
Kontrola dostępu · HIPAA §164.312(a) · SOC 2 CC6¶
| Kontrola | Mechanizm | Trzymane przez |
|---|---|---|
| Izolacja tenantów, nawet gdy wywołujący jest właścicielem wiersza | resolve_access odmawia zasobowi, którego organization_id się różni, zanim sprawdzi własność (app/services/access.py) |
test_resource_access.py::TestTenantBoundary, test_conversation_tenant_isolation.py, test_platform_flows.py |
| Uprawnienie na każdym route'cie kolekcji | Zależność route'u require(*perms) na listowaniu, tworzeniu i route'ach katalogu (app/api/deps.py), katalog w app/core/permissions.py |
test_platform_routes.py::TestEachRouteDemandsItsOwnPermission |
| Route'y per zasób autoryzują w serwisie, nie na route'cie | Route działający na jednym agencie, skillu albo kolekcji nie niesie bramki require() — bramka roli odmówiłaby posiadaczowi grantu, zanim grant by zadziałał — i zamiast tego woła resolve_access (app/services/access.py) |
test_platform_routes.py::TestEveryPlatformRouteIsGuarded (każdy route jest bramkowany albo rozstrzygany w serwisie) |
| Grant poszerza dostęp, nie awansując członka | resolve_access per zasób bierze max(zakres roli, grant) (app/services/access.py) |
test_resource_access.py::TestGrantsWidenAccess, ::TestPermissionsGrantsCannotWiden |
| Wzmianka w kanale uruchamia run jako nadawca, nie jako bot | Używany jest własny AuthContext powiązanego, aktywnego nadawcy (app/services/channels/mentions.py) |
test_channel_mentions.py::TestAnswer::test_the_run_carries_the_senders_own_role |
Uwierzytelnianie · HIPAA §164.312(d) · SOC 2 CC6¶
| Kontrola | Mechanizm | Trzymane przez |
|---|---|---|
| JWT (HS256), hasła przez bcrypt | app/core/security.py — verify_token, get_password_hash |
test_security.py, test_auth.py |
| Klucze API porównywane w stałym czasie | secrets.compare_digest (app/api/deps.py) |
test_auth.py, sprawdzenia HMAC webhooków w adapterach kanałów |
| Sesje w bazie danych z unieważnianiem | Tabela sessions + SessionService; token związany z claimem sid (app/services/session.py, app/api/routes/v1/sessions.py) |
test_session_verify.py, test_session_revocation.py |
| Logowanie jednokrotne przez własnego dostawcę tożsamości wdrożenia | Generyczne OIDC przez discovery — authorization code z PKCE, wymagane email_verified, konto kluczowane po sub (app/core/oauth.py, app/api/routes/v1/oauth.py). Entra ID, Okta, Keycloak; konfiguracja w Logowaniu jednokrotnym |
test_oidc_sign_in.py |
| Polityka rejestracji bramkuje SSO tak samo jak formularz | check_may_register wewnątrz get_or_create_oauth_user — invite_only i lista dozwolonych domen odmawiają też logowaniu przez dostawcę (app/services/user.py) |
test_oidc_sign_in.py::TestTheRoundTrip, test_signup_policy.py |
| Logowanie kontem katalogowym | Standardowy dwuetapowy bind LDAP — wyszukanie jako konto serwisowe, potem bind jako to konto z wpisanym hasłem; puste hasło nigdy nie dociera do bindu, nazwa użytkownika jest escapowana do filtra, dwa pasujące konta oznaczają odmowę, TLS jest weryfikowany, a ldap:// bez StartTLS jest odrzucane przy starcie (app/services/directory/ldap_directory.py). Zobacz Logowanie katalogowe |
test_ldap_directory.py, test_directory_settings.py |
| Zintegrowane logowanie Windows | SPNEGO z keytabem tej usługi; akceptowany jest wyłącznie bilet przyjęty w jednym kroku, a jego principal jest rozwiązywany przez katalog do tego samego konta, do którego prowadzi hasło (app/services/directory/kerberos.py, app/api/routes/v1/directory_auth.py) |
test_kerberos_acceptor.py, test_directory_auth_routes.py |
| Grupy katalogowe decydują o członkostwach | Mapowania grup katalogowych, stosowane przy każdym logowaniu LDAP, Kerberos albo OIDC. Synchronizacja przepisuje tylko członkostwa, które sama utworzyła, nigdy nie degraduje ani nie usuwa ownera, nigdy nie dołącza do organizacji osobistej i nie potrafi zmapować roli, której jej autor nie mógłby nadać; przekroczenie limitu grup w Entra ID (group overage) jest odrzucane (app/services/directory/sync.py, app/services/directory/mappings.py, groups_claim w app/core/oauth.py) |
tests/integration/test_directory_groups.py, test_oidc_groups.py |
| Granty dla grup | Grant ma dokładnie jeden podmiot, osobę albo grupę (ck_resource_grant_one_subject); grant dla grupy sięga do każdego, kto jest w grupie w chwili sprawdzania dostępu, i tylko przez grupy tej samej organizacji (app/repositories/resource_grant.py) |
tests/integration/test_directory_groups.py, test_resource_grant_repo.py |
| SAML, SCIM, ponowne sprawdzanie katalogu między logowaniami | Jeszcze nie — otwarta sesja wyłączonego konta katalogowego trwa, dopóki nie wygaśnie jej refresh token, chyba że administrator dezaktywuje konto. Zobacz Czego to jeszcze nie robi | — |
| Limitowanie prób logowania | enforce_auth_limit (app/api/deps.py) |
test_auth_rate_limit.py |
| Zmieniony adres e-mail jest dowodzony, zanim poczta za nim pójdzie | PATCH /users/me odkłada adres w users.pending_email i wysyła na niego jednorazowy, godzinny link; konto do powrotu tego linku odbiera wszystko pod dotychczasowym adresem, a ten dotychczasowy dostaje informację, że o zmianę poproszono. Link niesie wersję poświadczeń konta, więc zmiana albo reset hasła — to, do czego wzywa tamta informacja — unieważnia go, a naprawa adresu przez administratora czyści odłożoną zmianę. Ponowna prośba o już odłożony adres nie wysyła nic, a liczba różnych adresów na konto jest ograniczona w ciągu godziny. I żądanie, i potwierdzenie trafiają do audytu (app/services/user.py, POST /auth/email-change/confirm) |
test_email_change.py |
| Odtworzony refresh token kończy swój łańcuch i zostaje zapisany | Rotacja zachowuje zastąpiony hash; refresh, który do niego pasuje, to przypadek ponownego użycia z RFC 6819 §5.2.2.3 - zamyka tę sesję i zostawia wpis w audycie (SessionService.detect_refresh_reuse). Token przedstawiony w ciągu REFRESH_REUSE_GRACE_SECONDS (domyślnie 60) od swojej rotacji to utracona odpowiedź albo jedno z kilku równoczesnych żądań z tym samym cookie, a nie odtworzenie, więc dostaje następcę, którego wiersz już trzyma, odtworzonego ze zużytego tokena - każde żądanie z takiej serii dostaje ten sam token (SessionService.claim_refresh_grace, reissue_within_grace); to okno jest świadomie przyjętym kosztem |
test_session_revocation.py::TestReusingASpentRefreshToken, TestTheReuseGraceWindow |
| Klucze API organizacji | Uprawnienia członka zawężone do tych, z którymi klucz wydano, i odczytywane z członkostwa wydającego przy każdym żądaniu; w spoczynku SHA-256, pokazywany raz, opcjonalne wygaśnięcie, unieważnialny, limit per klucz, przyjmowany tylko na trasach publicznych, a we wpisach audytu żądania nazwany prefiksem (app/services/api_key.py, app/api/public_api.py) |
tests/integration/test_api_keys.py, test_api_key_service.py |
| OAuth 2.1 dla klientów MCP | Tylko klienci publiczni, wymagane PKCE (S256), redirect URI porównywany dokładnie; zgoda w konsoli odrzuca sesję impersonacji i uprawnienia, których członek nie ma; kody są jednorazowe, refresh tokeny rotują, a ponowne użycie unieważnia grant z każdym wydanym tokenem; token jest zużywany jednym warunkowym update'em, więc dwie równoległe wymiany nie wydadzą obu; otwarta rejestracja jest limitowana per adres, a nieużyci klienci znikają po dobie (app/services/oauth_server.py, app/services/platform_mcp/_oauth.py) |
tests/integration/test_mcp_oauth.py |
| Zdarzenia o zmianach na żywo | Publikowane dopiero po udanym i zatwierdzonym zapisie przez publiczne API; dostęp każdego subskrybenta jest odczytywany od nowa przy każdym zdarzeniu, więc wiersz, którego nie może odczytać, zmiana w innej organizacji ani usunięcie rodzaju, którego jego rola nie widzi w całości, nigdy do niego nie trafia, a gniazdo, którego sesja, klucz lub członkostwo wygasły, zostaje zamknięte (app/services/change_feed.py) |
tests/integration/test_change_feed.py |
Kontrole audytowe · HIPAA §164.312(b) · SOC 2 CC7¶
| Kontrola | Mechanizm | Trzymane przez |
|---|---|---|
| Mutacje istotne dla governance zapisywane w transakcji żądania | record_audit (app/core/audit.py) w mutującym serwisie — rotacja sekretu, podpięcie skilla / synchronizacji / MCP, członkostwo, udostępnianie, zatwierdzenia, eksporty i więcej; zapisywane do app_admin_audit_logs. To nie jest pokrycie każdego zapisu (CRUD bazy wiedzy, choćby, nie jest audytowany) |
test_skill_binding_audit.py, test_sync_source_audit.py |
| Ślad jest czytelny dla audytora | GET /audit, bramkowane na audit:read (app/services/audit.py) |
test_audit_service.py |
| Eksport śladu (CSV/JSONL) | GET /audit/export w oknie czasu, bramkowany na audit:read, zapisujący własny odczyt w śladzie; eksporty runów, zatwierdzeń i wydatków robią to samo (#1422) |
test_exporting.py (eksport i jego własny wpis audytowy) |
| Okres audytu, który organizacja może wydłużyć i nigdy skrócić | Podłoga na poziomie wdrożenia (domyślnie sześć lat, HIPAA §164.316(b)(2)); krótszy okres jest odrzucany, a nie podnoszony. Sweep nie usuwa wpisów audytowych — łańcuch haszy i jego checkpoint stoją na tym, że wpisy zostają, więc wpisu nie da się wycofać, nie łamiąc dowodu, że reszta jest nienaruszona (app/core/retention.py). Zobacz Retencję |
test_retention.py::TestWhichNumberWins, ::test_audit_resolves_to_a_period_and_is_still_not_swept |
| Osoba może odczytać i usunąć własne dane | GET /me/data/export (limit na godzinę, audytowany także przy własnym żądaniu) oraz DELETE /conversations/{id} w zakresie własności wywołującego; eksport administratora wymaga powodu (app/services/personal_data.py) |
test_personal_data.py |
| Dowód nienaruszalności (łańcuch haszy) | Każdy wpis wchodzi w łańcuch haszy swojej organizacji, a każdy łańcuch niesie checkpoint na swoim znaku najwyższej wody, więc przepisany wpis, urwany ogon i skasowany łańcuch są wykrywalne osobno; agenticos cmd audit-verify przechodzi je i kończy się kodem niezerowym na przerwaniu (app/core/audit.py, app/commands/audit_verify.py). Wykrycie, nie zapobieżenie: kto trzyma własne poświadczenia bazy, może przekuć łańcuch albo zrzucić trigger checkpointu |
test_audit_record.py, test_audit_chain_backfill.py, test_audit_verify_command.py, test_audit_checkpoint_migration.py |
Integralność · HIPAA §164.312(c) · SOC 2 CC8 (zarządzanie zmianą)¶
| Kontrola | Mechanizm | Trzymane przez |
|---|---|---|
| Spec jest odrzucany przy publikacji, nigdy w czasie runu | validate_spec (app/services/agent_registry.py) — nieznana capability, nieprzyznany scope, secret_id złego rodzaju albo z innej organizacji, osobiste połączenie MCP |
test_agent_registry.py, test_capability_secrets.py::TestPublishValidation |
| Budżet jest sprawdzany przed żądaniem do modelu, a koszt zapisywany nawet przy błędzie | BudgetGuard.wrap_model_request bramkuje przed wywołaniem (app/agents/capabilities/budget/); koszt runu jest zapisywany w terminalnym finally (app/services/agent_runner.py) |
test_spend.py::TestBudgetGuard, test_agent_runner.py::…::test_a_failed_run_still_records_its_cost |
| Zatwierdzenie jest rozstrzygane dokładnie raz | ApprovalService.decide odmawia wierszowi innemu niż oczekujący, odczytanemu for_update (app/services/approvals.py) |
test_approvals_queue.py::TestDecidingTwiceIsRefused |
| Zatwierdzenie w rozmowie głosowej wykonuje dokładnie pokazane argumenty, raz, i rozstrzyga je tylko rozmówca | Most rozmowy zapisuje wiersz, pokazuje dokładne argumenty, czeka 60 s i przyznaje zgodę po id wywołania narzędzia (app/services/voice/approvals.py); route kolejki odrzuca wiersze trwającej rozmowy |
test_voice_approvals.py::test_an_approval_grants_exactly_the_shown_arguments_once, tests/integration/test_voice_approvals_queue.py |
| Rozmowa głosowa zatrzymuje się, gdy znika dostęp, i jest rozłączana przy każdym wyjściu | Każdy 15-sekundowy heartbeat sprawdza na nowo sesję, członkostwo, stan publikacji agenta i przełącznik organizacji; każde wyjście uruchamia jedno osłonięte sprzątanie, które zapisuje koniec rozmowy, a potem rozłącza ją u providera; przemiatanie co minutę i przy starcie rozłącza rozmowy, których heartbeat się zestarzał (app/services/voice/session.py, app/services/voice/reaper.py) |
test_voice_session.py::test_a_call_stops_being_allowed_when_access_goes, ::test_every_exit_path_hangs_up_the_provider_call, test_voice_reaper.py::test_a_call_whose_heartbeat_stopped_is_hung_up_and_its_run_closed |
| Analiza statyczna dosięga zmiany, która wprowadza znalezisko | CodeQL (security-extended) na każdym pull requeście dla Pythona, JavaScriptu/TypeScriptu, Rusta i workflowów, plus cotygodniowy pełny przebieg (.github/workflows/codeql.yml). Scalenia odmawia ochrona scalania na podstawie code scanningu w rulesecie main, a nie status samego joba — zobacz gałęzie |
test_codeql_workflow.py |
| Zależność ze znaną podatnością przerywa pull requesta | make audit na backend/uv.lock i make audit-frontend na frontend/bun.lock, oba w zadaniu Security Scan i w make check |
test_ci_parity.py |
| To, co zawiera wydanie, da się odczytać bez budowania | SBOM CycloneDX na obraz, generowany z opublikowanego manifestu i dołączany do wydania; inwentarz komponentów jest czytelnym indeksem | test_images_workflow.py::TestTheReleaseCarriesAnInventory |
Poufność poświadczeń · HIPAA §164.312(a)(2)(iv)¶
| Kontrola | Mechanizm | Trzymane przez |
|---|---|---|
| Żaden sekret otwartym tekstem w odpowiedzi API ani we wpisie audytowym | SealedStr/CredentialStr maskują każdy repr; podpowiedzi to wyłącznie ostatnie 4 znaki (app/core/secret_kinds.py, app/core/vault.py) |
test_no_secret_escapes.py (przemiata całą powierzchnię OpenAPI), test_capability_secrets.py::TestInjection |
| Logi nie są częścią tej gwarancji | Zniekształcona odpowiedź tokena MCP OAuth trafia do logów przez ValidationError Pydantica, który powtarza swoje wejście — znana luka, #1626 |
test_mcp_connections.py::test_an_unreadable_token_response_does_not_echo_its_input (dokumentuje, że token ląduje w caplog) |
| Podpisane linki w logach dostępu | Filtr redakcji obejmuje poza logami aplikacji także logi dostępu i błędów uvicorna (app/core/logging.py), więc podpisany ?token= - gniazda embed czy linku do wygenerowanych mediów - jest tam zapisywany zredagowany; dostarczony nginx/nginx.conf loguje ścieżki bez query stringa; log dostępu Traefika (traefik/traefik.yml), innego reverse proxy lub serwera frontendu leży poza aplikacją, zapisuje te tokeny z zapytania jawnym tekstem i należy go przechowywać i udostępniać jak log zawierający aktywne, krótko żyjące poświadczenia |
test_logging.py::test_a_signed_link_in_the_access_log_is_redacted |
| Żadne poświadczenie providera nie trafia do przeglądarki rozmowy głosowej | Serwer odpowiada na ofertę SDP i trzyma połączenie sideband; oferta z kanałem danych jest odrzucana; sekrety klienta nigdy nie są wydawane; klucz jest odpieczętowywany na rozmowę, a na rozłączenie tylko przez OrganizationSecretService.voice_call_key, którego otwarty tekst trafia do endpointu rozłączania providera i nigdzie indziej (app/services/voice/transport.py, app/services/voice/hangup.py) |
test_voice_session.py::test_no_frame_carries_the_provider_key, test_voice_transport.py::test_an_offer_with_a_data_channel_is_refused |
| Poświadczenie jest w spoczynku związane ze swoją organizacją | Koperta HKDF per właściciel (app/core/vault.py); zakres to poświadczenia konektorów i API — o tokenach bearer, których nie obejmuje, mówi „Co jest gdzie szyfrowane” |
test_secret_tenant_isolation.py, test_vault.py |
Bezpieczeństwo transmisji · HIPAA §164.312(e) · SOC 2 CC6¶
| Kontrola | Mechanizm | Trzymane przez |
|---|---|---|
| TLS do PostgreSQL i Redisa | POSTGRES_SSLMODE, REDIS_SSL (app/core/config.py); doctor raportuje żywy stan Postgresa z pg_stat_ssl |
Postgres, na żywym połączeniu: test_store_tls.py; Redis, przy budowie URL-a i w doctor: test_config.py, test_doctor_sandbox.py |
| Nagłówki ramkowania i MIME na każdej odpowiedzi; CSP na wszystkich poza endpointami referencji API | SecurityHeadersMiddleware (app/core/middleware.py), którego exclude_paths zdejmują CSP — nie ramkowanie ani MIME — dla OpenAPI, Swaggera i ReDoc; plus CSP frontendu per wdrożenie (frontend/src/middleware.ts), którego script-src niesie nonce per żądanie i 'strict-dynamic' zamiast 'unsafe-inline' |
test_security_headers.py, w tym test_an_excluded_path_keeps_its_framing_but_drops_the_csp; csp.test.ts, middleware.test.ts |
| HTTPS i HSTS | Terminowane na reverse proxy — dołączony nginx/nginx.conf ustawia HSTS; aplikacja z założenia nie |
Sprawa wdrożenia; zobacz listę kontrolną hardeningu |
| Strony pisane przez agentów nie mogą dosięgnąć konsoli | Opublikowana aplikacja jest serwowana z trasy bez ciasteczek, za krótkożyjącym podpisanym tokenem, pod Content-Security-Policy: sandbox bez allow-same-origin — nieprzezroczysty origin — i bez allow-popups, plus connect-src 'none', bez zdalnego źródła poza własną ścieżką bibliotek wdrożenia, i frame-ancestors wymieniające tylko konsolę (a dla publicznej strony, którą można osadzić, dokument osadzenia i wskazane witryny); link na stronie otwiera się dopiero, gdy człowiek potwierdzi jego adres, a follow-up, o który prosi, jest wysyłany dopiero, gdy człowiek go przeczyta i kliknie Wyślij; jedynym wyjątkiem dla obrazów jest własna ścieżka kafelków deploymentu, która przyjmuje trzy sprawdzone liczby i sama pobiera kafelek z serwera wskazanego przez VISUAL_MAP_TILES, więc strona nigdy do tego serwera nie sięga; opcjonalnie z własnej domeny rejestrowalnej (ARTIFACT_ORIGIN) |
test_artifact_routes.py::TestTheContentRoute, test_artifact_service.py::TestThePolicy, test_artifact_pages.py, test_map_tiles.py, test_tile_proxy.py, artifacts.test.tsx, visual-frame.test.tsx, visual-panel.test.tsx, csp.test.ts |
| Limity zapytań na publicznych powierzchniach | Limity oparte o Redis na API runów, widgecie embed, stronach hostowanych, publicznych linkach aplikacji i ich podpisanych adresach treści oraz podpisanych linkach do wygenerowanych mediów, które dostaje odwiedzający stronę hostowaną, liczonych per link (app/services/rate_limit.py); limity per nadawca na botach kanałów (app/services/channels/router.py) |
test_rate_limited_surfaces.py; limit bota kanału jest zaimplementowany, ale cienko przetestowany |
| Wyciekły link embeda ogranicza, ile mogą kosztować rozmowy | Starty rozmów głosowych są limitowane per adres, per link i per podmiot jwt, na jednym embedzie otwartych jest najwyżej VOICE_MAX_CALLS_PER_EMBED rozmów, a każdą ogranicza limit długości rozmowy i budżet (app/services/rate_limit.py, app/services/voice/starter.py) |
test_rate_limit.py, test_voice_starter.py, tests/integration/test_voice_call_slot.py |
| Dyktowanie kosztuje najwyżej tyle, ile jego najdłuższa długość | Domyślnie tylko WAV z 16-bitowym PCM 16 kHz mono: parsowany ściśle, z policzonymi próbkami, odrzucany ponad dictation_max_seconds, zanim dostawca zostanie wywołany, i wysyłany za nagłówkiem zbudowanym przez serwer (app/services/ml/pcm_wav.py, MLService.transcribe_dictation). WebM, Ogg i MP4 dopiero wtedy, gdy app admin włączy dictation_compressed_audio, za oknem dialogowym, które nazywa ryzyko |
test_pcm_wav.py, test_ml_service.py, dictation-audio-card.integration.test.tsx |
Odmowy jako zbiór¶
Testy odmów powyżej niosą marker security. make test-security uruchamia cały
zbiór, a CI publikuje zebraną listę jako artefakt security-tests.txt przy każdym
przebiegu backendu (#1417) — więc odmowy da się policzyć i przeczytać, a nie tylko
przyjąć na wiarę. Test, którego nazwa albo moduł wspomina tenanta, uprawnienie,
budżet, zatwierdzenie, sekret albo tekst jawny, a nie ma markera, wywala
tests/test_security_marker.py, co trzyma listę kompletną w miarę rozrostu suite.
Profil HIPAA i czego nie twierdzi¶
Przegląd bezpieczeństwa nie pyta „czy to oprogramowanie jest zgodne”. HHS nie certyfikuje żadnego oprogramowania, a OCR nie uznaje prywatnych certyfikacji. Pyta czy możemy to uruchomić wewnątrz naszego zgodnego środowiska i czy da się to udowodnić — a odpowiedzią jest konfiguracja dostarczona z produktem plus komenda sprawdzająca działające wdrożenie względem niej (#1448).
Jeden wiersz na kontrolę, każdy nazywający ustawienie, które ją spełnia, albo to,
które jej nie spełnia, i niezerowy exit przy jakiejkolwiek porażce, żeby dało się
to odpalić w CI klienta. Konfiguracja to deploy/profiles/hipaa/: overlay compose
odmawiający startu bez ustawień, których nie może domyślnie przyjąć, i opisany
plik env.
Ten akapit czytaj jednym tchem z profilem. Odpowiada on na techniczne zabezpieczenia, §164.312, i tylko na nie. Zabezpieczenia administracyjne (§164.308 — analiza ryzyka, szkolenia, polityka sankcji, plan ciągłości, umowy z business associate) i fizyczne (§164.310) należą do operatora i zawsze będą. Profil sugerujący inaczej byłby twierdzeniem, którego nikt nie obroni.
Arkusz¶
| Kontrola | Zabezpieczenie | Spełnia ją |
|---|---|---|
postgres-tls |
§164.312(e)(1) | POSTGRES_SSLMODE=verify-full. require szyfruje i nie weryfikuje żadnego certyfikatu, więc profil go nie przyjmuje |
redis-tls |
§164.312(e)(1) | REDIS_SSL=true. Oba magazyny muszą być twoje: dołączone w repozytorium db i redis nie mają listenera TLS, więc overlay odmawia startu bez POSTGRES_HOST i REDIS_HOST |
browser-tls |
§164.312(e)(1) | FRONTEND_URL i PUBLIC_BASE_URL po https, z twoim reverse proxy je terminującym. Poświadczane, a adres http jest tu odrzucany: każda inna kontrola może przejść, podczas gdy logowanie przechodzi granicę klienta otwartym tekstem |
vault-key |
§164.312(a)(2)(iv) | Klucz główny vaulta o długości co najmniej 64 znaków. HKDF wyprowadza z czegokolwiek klucz opakowujący właściwej długości i nie doda entropii do zgadywalnego sekretu |
content-at-rest |
§164.312(a)(2)(iv) | Operatora. Dane Postgresa, wolumen mediów i katalog workspace'ów sandboxa szyfruje wolumen albo dysk, nie ta aplikacja |
local-model |
§164.312(e)(1) | Każdy profil modelu serwowany z twojej sieci. Parsowana jest nazwa hosta — adres prywatny, localhost, gołe ollama/litellm/vllm albo nazwa .internal/.local/.svc — więc https://ollama.vendor.example nie jest lokalny, a ten bez base_url to z definicji publiczne API dostawcy |
traces-local |
§164.312(e)(1) | Nieustawiony LOGFIRE_TOKEN i żaden opublikowany agent ani nazwane środowisko nie niesie własnego tokenu tracingu — każdy podpina własny eksporter, a observability.content domyślnie to full |
sso |
§164.312(d) | OIDC_ISSUER, konfigurowany samym discovery — issuer to jedyny URL, a endpointy autoryzacji, tokenu i JWKS biorą się z jego .well-known/openid-configuration — albo KERBEROS_ENABLED, logowanie własnym biletem domeny. Gdy żadne nie jest ustawione, kontrola jest niespełniona i arkusz to mówi: przy samym LDAP_URL mówi, że hasła należą do katalogu, ale są wpisywane tutaj, bez logowania jednokrotnego, a w przeciwnym razie, że ludzie logują się hasłami, które to wdrożenie przechowuje. Uwierzytelnianie wieloskładnikowe należy do dostawcy tożsamości, a arkusz nie przypisuje go sobie w żadną stronę |
signup |
§164.312(a)(1) | invite_only albo closed |
audit-retention |
§164.312(b) | Podłoga audytu co najmniej 2190 dni — sześć lat z §164.316(b)(2) |
audit-chain |
§164.312(c)(1) | Łańcuch haszy i jego checkpoint. Wykrywanie, nie zapobieganie — zobacz Kontrole audytu |
Trzy wyniki i to środkowy waży. ok i !! to odpowiedzi tego kodu. -- to
kontrola, która naprawdę należy do operatora, nazwana, a nie po cichu
zaliczona — arkusz pomijający to, czego nie widzi, czytałby się jako kompletny i
nie byłby — i nie wywala komendy, bo kontrola, której stąd nikt nie udowodni, to
kontrola, której nikt nigdy by nie przeszedł.
Kto jest business associate¶
Klient uruchamiający to na własnej infrastrukturze dostaje oprogramowanie. Nikt tutaj nie dotyka jego PHI i żadna umowa nie jest potrzebna. Wdrożenie prowadzone dla niego przez kogoś innego czyni tego kogoś business associate, a to umowa, nie flaga w konfiguracji.
Dlaczego profil domyślnie bierze model lokalny¶
Hostowany model zabiera ze sobą treść każdego uruchomienia, więc jego użycie
oznacza umowę z tym dostawcą — a te umowy są węższe, niż ludzie zakładają.
Organizacja z włączonym HIPAA u dużego dostawcy zwykle wyklucza wykonywanie kodu
i pobieranie z sieci, czyli dokładnie kształt capability sandbox,
code_execution i web_fetch. Klient, który taką umowę podpisze, a potem
zbuduje agenta na tych capability, dowiaduje się o tym w trakcie incydentu.
Lokalna inferencja usuwa to pytanie i dlatego jest domyślną wartością profilu, a nie sugestią.
Podsumowanie¶
- Ufaj infrastrukturze operatora; nie ufaj żadnemu żądaniu do niej. Granicami, które mają znaczenie, są przeglądarka → BFF → API → magazyn oraz API/worker → strony trzecie. BFF przekazuje ciasteczko sesji; API jest tym miejscem, gdzie żądanie jest weryfikowane.
- Jedyne dane, które wychodzą, to te, o których wyjściu zdecydowało wdrożenie —
providerzy modeli, kanały, serwery MCP, dostawcy wyszukiwania i embeddingów
oraz Logfire — który jest opcjonalny, a gdy token na poziomie wdrożenia jest
ustawiony, trace'uje każdy run obsłużony przez proces API, z treścią każdego
agenta, który nie poprosił o
none. - Poświadczenia konektorów i API są zapieczętowane per organizacja w jednym
vaulcie; krótkożyjące tokeny bearer i reszta treści w spoczynku (wiadomości,
RAG, sandboksy) nie są. Wgrane pliki są tym, co się zmieniło:
FILE_STORAGE_BACKEND=s3umieszcza je w object storage, który szyfruje każdy zapis. - Każda kontrola w macierzy nazywa mechanizm i test, i tym samym tchem nazywa swoje luki — dowód nienaruszalności linkuje issue, które by go zbudowało.
- Podatności zgłaszaj i listę kontrolną hardeningu uruchamiaj z
SECURITY.md; obok tej strony czytaj Ochronę danych i Licencje.