fahmek: architecture and data flow
For the security committee. Engine version: see engine_version in any API
response.
1. What the service does, in one sentence#
fahmek sits between the organization's LLM gateway (LiteLLM, Portkey, Kong…) and the model providers, and replaces Moroccan personal identifiers in messages with opaque tokens before the text leaves the perimeter. Identifiers that are detected and masked by the policy are replaced with tokens. Coverage is not absolute: an identifier without context can stay below the threshold, and a fail-open policy can let a request through during an outage. The per-register evaluation must measure these limits before any coverage promise.
2. Components#
| Component | Role | Network |
|---|---|---|
| fahmek (HTTP service, Python/FastAPI) | Detection, decision, masking, restoration | Listens on the internal network only |
| Redis (optional) | Re-identification map token → value, in memory, per tenant, short lifetime | Internal only, no persistence |
LiteLLM guardrail (integrations/litellm/fahmek_guardrail.py, or fahmek's "Generic Guardrail" API from LiteLLM's web UI) | Calls fahmek before the model call and after the answer | Talks to fahmek and to the proxy, never to the provider |
| auto-gad + LanguageTool (optional) | Fixes French and English spelling of the already-masked text; never blocks, never logs the text | Internal only, self-hosted LanguageTool |
Recognizer packs (recognizers/*.yaml) | Patterns, context words, checksums, test cases | Files loaded at startup |
Policies (policies/*.yaml) | Active entities, actions, thresholds, fail_open, reversibility TTL | Files loaded at startup |
The service ships no machine-learning model: regular expressions,
checksums (mod 97, ISO 7064) and context words. It makes no network call
on the request path, apart from Redis when reversibility is explicitly
requested. On Kubernetes it reads, once at start, the ID of the cluster's
kube-system namespace from the cluster's own API server, to check a
license bound to that cluster; this never leaves the cluster. No license
server, no download at runtime: it runs isolated from the network
(air-gapped). The only outbound connection is the license integrity
report, off unless the customer turns it on: every 4 hours, one HTTPS
request to fahmek.com with the license status only (no text, no personal
data), from a background task. The license works the same whether it gets
through or not.
3. How a request flows#
user ──▶ LLM gateway ──(1)──▶ fahmek /v1/inspect ──▶ decision
│
masked text ◀──(2)────────┘
│
(3) ──▶ model provider (sees tokens only)
│
answer ──(4)──▶ fahmek /v1/restore ──(5)──▶ user- Inspection. The gateway sends every text message bound for the
provider (
user,system,developer,assistant,tool) toPOST /v1/inspectwith the policy and tenant identifiers and an opaque namespace of its own per message. Two identifiers of the same type in different messages therefore never share a token. OpenAI arrays oftype: textparts are joined for inspection, then sent back as one masked text part. With the integration kit, function-call arguments are decoded, inspected and re-encoded without changing their structure; from LiteLLM's web UI, a request holding an identifier there is refused. Images, audio and unknown formats are refused (400). - Three-valued decision.
allow,redactorblock, never a boolean.redactreturns the original text with the values replaced by«ENTITY_n»tokens;blocknever returns text. - Forwarding. The allowed or masked text goes to the provider.
- Restoration (when
reversible: true). The model's answer goes throughPOST /v1/restore; tokens are replaced with the original values, streamed answers included. - Answer to the user, with their identifiers intact.
Inside an inspection#
- Normalization: NFKC, Arabic-Indic digits (
٠١٢٣,۰۱۲۳) converted to ASCII, tatweel and bidirectional control characters (U+202E…) removed. An index map links each position of the normalized text to the original position, in Unicode codepoints. This defeats the usual evasions: digits in Arabic script, a bidi control inserted in the middle of a number. - Detection on the normalized text: patterns, then checksum (a RIB with an invalid key is not a RIB), then confidence adjusted by context words and best-effort prefix lists.
- Policy: action per entity (inherited from the pack or overridden),
thresholds per action, safe cascade (a
blockbelow its threshold becomesredact, neverallow). - Masking on the original: positions are projected back onto the original text; the unmasked parts are byte-for-byte identical to the input. The normalized text never leaves the service.
4. What is stored, and where#
| Data | Location | Duration | Logged? |
|---|---|---|---|
| Request text | Nowhere | — | No |
| Detected values | Nowhere unless reversible: true | — | Never |
token → value map (reversibility) | Redis, in memory, key derived from tenant_id + request_id (SHA-256) | Policy TTL (300 s by default), consumed by the first restore | No |
Results (findings) | HTTP response only: type, positions, confidence, action | — | Up to the gateway |
Non-negotiable rule: Redis runs with save "" and appendonly no.
fahmek checks these settings at startup; it warns loudly by default and
refuses to start when a loaded policy is fail_open: false. A
deployment with persistence enabled breaks the contract.
Isolation between tenants: tenant B cannot restore a request of tenant A (a different key by construction); the answer is the same whether a map does not exist or belongs to another tenant, so nothing reveals that it exists.
5. Failure modes#
| Situation | Behavior | Signal |
|---|---|---|
Redis unreachable, reversible requested, fail_open: true policy | Masking done; only reversibility degraded | degraded: ["reversible"] |
Same, fail_open: false policy | block decision, no text returned | degraded: ["reversible"] |
| Engine error | allow (uninspected text) if fail_open: true, otherwise block | findings: [] |
| fahmek unreachable from the gateway | The guardrail follows the policy's fail_open, cached from GET /v1/policies; unknown policy ⇒ refusal | Log fahmek.degraded / fahmek.unreachable |
| Restoration impossible | The answer is returned with its tokens (the values stay protected) | Log fahmek.degraded stage=post_call |
The safe direction is always the same: when in doubt, mask more, never leak.
6. Stated limits#
Detection is probabilistic for identifiers without a public specification (CIN, CNSS) and shape-only for those whose check algorithm is not published (ICE). The detection catalogue, generated from the packs, gives each identifier its confidence level and the exact wording of its limits. Names, addresses and dates of birth are not detected. Out of scope in v1: Arabic named-entity recognition, prompt-injection detection, detection of personal data newly generated in answers, image and audio inspection, control plane UI.
A guardrail or callback that sends text off site (a cloud scanner such as Prisma AIRS) must run after fahmek for the request and before fahmek for the answer; otherwise it receives the original values. LiteLLM's guardrail order and the settings to avoid are in Beside other guardrails.
7. Evidence#
- Automated test suite run on every change: normalization (round-trip property on arbitrary inputs), packs (cases in the five language registers, negatives included), API (reference request/response pairs), reversibility (byte-for-byte identical round trip, expiry, Redis down, tenant isolation), guardrail, latency budget (p99 < 15 ms, measured per register at 4,096 and 8,192 codepoints).
- End-to-end integration test: full compose stack, payload received by the
provider checked free of original identifiers, on several LiteLLM
releases (
integrations/litellm/COMPATIBILITY.md). - Order beside another guardrail: LiteLLM's Prisma AIRS guardrail, pointed
at a stand-in service that records what it receives, only sees masked
text in the recommended order (
tests/test_litellm_third_guardrail.py). - No real personal data in the repository: every test set is synthetic, with reserved or fictitious prefixes.
This page is the guide shipped with fahmek, kept in sync with each release.