Self-hosted LLM security proxy. PII redaction, prompt injection defense, KVKK/GDPR/PCI-DSS compliance. Sub-millisecond latency
See the codeA guardrail for LLM traffic that you run on your own servers.
Tamga sits in front of the model APIs your applications, RAG pipelines and
agents call. It scans each request routed through it and blocks or redacts
PII, secrets and prompt injection before the request is forwarded to the provider.
Website · Quick start · How it works · Benchmarks · Docs · Status
An application that calls a model API forwards whatever it is handed. A support bot passes on a ticket with the customer's national ID in it, and a RAG pipeline retrieves a document that holds an IBAN or an API key. An agent reads a web page that carries instructions written for the model, and that text arrives in its next request as a tool result. The usual controls do not help:
Tamga is a reverse proxy you run yourself. It speaks the OpenAI and Anthropic
APIs, so applications only change their base URL. It reads each request
message by message and knows whether a piece of text is the user's prompt, a
tool result, a tool description or the system prompt. Scanners look for PII,
secrets and injection wording in that text, and a YAML policy turns each
finding into a block, a redaction, a warning or a pass. A rule can be limited
to some of those roles, and a tool result that carries an injection can be
stripped so the agent's session continues. Responses are scanned too when the
policy's output_rules are on. Every decision is recorded in your own
Postgres. Built for KVKK, GDPR and
BDDK workloads, with first-class Turkish PII (TC Kimlik, Turkish IBAN and phone formats).
Tamga sees only the requests that are sent to it. It does not see:
More limits are listed under Project status.
Start with the proxy alone. It needs Docker and nothing else: no database, no keys.
git clone https://github.com/yatuk/tamga.git
cd tamga
cp .env.example .env # set TAMGA_MOCK_UPSTREAM=true to try it without provider keys
docker compose --env-file .env -f deploy/docker-compose.yml --profile minimal up -d
The proxy is on http://localhost:8443. Send a prompt carrying a Turkish
national ID. It is blocked before it reaches any provider:
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8443/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Müşteri TC 10000000146"}]}'
# 403
The proxy alone scans, applies the policy and forwards, but keeps no record: there is no request log, no dashboard, and API keys and settings made through the API are lost on restart. What it does and does not do is listed in docs/operations.md.
The full stack adds the request log in Postgres, the dashboard, the analyzer (PDF reports), Redis, NATS and Jaeger. Stop the proxy-only container first, as both use port 8443:
docker compose --env-file .env -f deploy/docker-compose.yml --profile minimal down
docker compose --env-file .env -f deploy/docker-compose.yml up -d
.env.example sets COMPOSE_PROFILES=full, so this starts the full stack;
--profile full does the same. Proxy on http://localhost:8443, dashboard on
http://localhost:3000. The dashboard holds no key: on first use it asks for
the admin key (TAMGA_ADMIN_KEY from .env) under Settings and keeps it in
that browser.
--env-file .envis required. Compose only auto-loads a.envnext to the compose file, so without it the database password is empty, Postgres will not start, andCOMPOSE_PROFILESis not read. An.envmade before 0.9.0 has noCOMPOSE_PROFILESline: addCOMPOSE_PROFILES=fullto it.
Point an existing SDK at the proxy:
import os
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8443/v1", # was https://api.openai.com/v1
api_key=os.environ["OPENAI_API_KEY"],
)
Provider routes: /v1 (OpenAI), /anthropic, /gemini, /azure, /bedrock,
/mistral, /local. The default policy allows the first four. Only the first
three are tested by forwarding a request to a stand-in provider; see
Project status.
flowchart LR
A[Your app] -->|OpenAI / Anthropic API| B[Tamga proxy]
B --> C[Scanners]
C --> D{Policy}
D -->|pass| E[LLM provider]
D -->|redact| E
D -->|block| F[403 + audit event]
E -->|response| G[Output scan]
G --> A
BLOCK, REDACT, WARN or
PASS. It hot-reloads, is parsed strictly, and warns at startup if nothing
acts on PII, secrets or injection.output_rules are on; they are
off in the shipped policy.notify: URL of the rule that matched.Diagrams for the request lifecycle, scanner pipeline, policy flow and database schema are in docs/architecture.
| Detects | Default action | |
|---|---|---|
| PII | TC Kimlik, credit cards (Luhn + BIN), IBAN, email, phone, IP | Block IDs and cards, redact the rest |
| Secrets | AWS, Azure storage, GitHub, GitLab, OpenAI, Anthropic, Stripe, Slack, npm and PyPI keys and tokens, JWTs, private keys, connection strings, credentials in URLs (all formats) | Block |
| Prompt injection | Instruction override, role manipulation, jailbreak patterns, encoded payloads, text hidden in invisible characters; EN/TR/DE | Block |
| Custom entities | Your own regex patterns (customer numbers, file IDs), editable from the dashboard | Per entity |
| System-prompt leaks | Canary token injected into the system prompt and looked for in the response | Block (opt-in) |
| Decision governance | Prompts that contradict a locked decision in a jugeni audit log | Per assertion (opt-in) |
Also in the box: reversible PII tokenization (vault), per-key rate limits, a daily budget per organisation, provider allowlists, a response cache, and a SOC dashboard with an incident queue. See docs/operations.md, and docs/agents.md for running Claude Code and other agents through it.
Six pages of the dashboard, on made-up data (how the pictures are taken):
Overview: what the proxy scanned in the last 7 days, what it found and what it stopped most recently.
Incidents: the requests the proxy acted on, newest first, with the proxy's own counts for the week.
Event explorer: every scanned request, with one stopped request open to show what was found and where.
Playground: a prompt tried against the running policy, with the text behind the verdict marked.
Policies: the policy document in the editor, saved and applied without a restart.
Traffic: how many requests went through, by day, by provider and by model family.
Measured on 2026-10-03. Everything here is reproducible from this repository.
Accuracy, as precision / recall, on two sets of prompts written for this
repository. The inline classifier is optional: a local model asked about what
the rules did not block, here
prompt-injection-guard-small
at threshold 0.98.
| Set | Prompts | Deterministic scanners alone | With the inline classifier |
|---|---|---|---|
Tuning corpus prompts.csv (gates CI) | 309, of which 117 benign | 1.000 / 0.896 | 1.000 / 0.927 |
Held-out set holdout.csv | 163, of which 85 benign | 1.000 / 1.000 | 1.000 / 1.000 |
On 2026-10-11 the tuning corpus grew by 22 prompts written together with new detectors (331, of which 121 benign). The deterministic scanners score 1.000 / 0.905 on it: the 18 added attacks are caught and the 20 misses are the same 20. A second change that day (translation requests no longer blocked, a URL's user and password no longer read as an e-mail address, instructions spelled one letter at a time read) added 30 more (361, of which 138 benign; the held-out set got 6 benign translation requests, 169 of which 91 benign): 1.000 / 0.910 and 1.000 / 1.000, with the same 20 misses. The classifier has not been run on the larger corpus.
Source: docs/benchmarks/README.md, from the reports redteam_latest.json, redteam_holdout.json, redteam_classifier_latest.json and redteam_classifier_holdout.json.
Read that honestly. The rules were written against the misses in the tuning corpus, so its recall says how well they fit it, not how they do on new traffic; on 2026-10-01, before that work, the same corpus scored 0.969 / 0.495. The held-out set was written separately but by the same author, in the same attack families, so it overstates too. Expect less on your own traffic and measure there. What the rules still miss in the corpus is semantic: fictional and hypothetical framings, data-exfiltration requests, health data, street addresses.
What the classifier added. On the tuning corpus it caught 6 attacks the rules missed and flagged no benign sample. On the held-out set it added nothing: the rules already caught every attack. The threshold was chosen on the tuning corpus as the lowest that flagged no benign sample, then run unchanged on the held-out set. The gain is small because both sets were built around what rules can catch; they are the wrong instrument for a model whose purpose is phrasing nobody anticipated, and no independent set has been run yet. A classifier call took p50 14–24 ms and p95 23–58 ms across runs on the machine below; text seen in the last ten minutes is answered from memory. At threshold 0.9 the classifier catches two more attacks in the tuning corpus and flags 1 of its 117 benign samples, and 2 of the 85 in the held-out set. It did not catch the three injection vectors that bypass the adversarial suite.
Latency. Scan stage: p50 0.2 ms, p95 0.7–0.8 ms. End to end through the Docker Compose stack with a mocked upstream:
| Load | P50 | P95 | P99 | Errors |
|---|---|---|---|---|
| 100 RPS | 2.6 ms | 4.0 ms | 5.4 ms | 0% |
| 500 RPS | 1.6 ms | 3.3 ms | 6.6 ms | 0% |
| 1000 RPS | 1.6 ms | 5.0 ms | 9.9 ms | 0% |
16-core laptop CPU, Docker limited to 12 GB. The medians repeat from run to run; the tails do not: runs on this day gave a P95 at 1000 RPS anywhere from 3.0 to 5.0 ms. These also depend on hardware: an earlier run on a 4-core machine held the same P95 to 500 RPS and reached 130 ms at 1000. The prompts are chat-sized; for large requests see docs/agents.md.
Adversarial suite. 84 attack vectors written to evade the scanners: 78 detected, 6 bypass. Nineteen of them attack the structure of the request rather than its wording: text in JSON escapes, in tool results, tool call arguments, tool descriptions and attachments, and bodies that parsers read differently. The bypasses are published in tests/stress/baseline.json and include leetspeak and character-by-character smuggling. That is the run of 2026-10-03: since 2026-10-11 the scanners read the two character-by-character vectors and the leetspeak one (measured in-process, docs/benchmarks, leetspeak), and the suite has not been run again, so the baseline still lists them. The vectors are known to the authors, so this is a regression suite, not an independent evaluation. With the classifier on, the result was the same 78 and 6, and the load figures were within the run-to-run spread of the table above (the load test repeats its prompts, so it exercises the classifier's cache, not the model). No result file for that run is in the repository; the baseline describes the run without the classifier.
cd proxy && go run ./cmd/redteam -in ./testdata/redteam/prompts.csv # accuracy
./tests/stress/run_stress_suite.sh # adversarial + load
Method and per-category results: docs/benchmarks · tests/stress
| Tamga | Hosted gateways | OSS gateways | Network DLP | |
|---|---|---|---|---|
| Runs entirely on your infrastructure | Yes | No | Yes | Usually |
| Turkish PII (TCKN, IBAN, phone) | Native | Not checked | Not checked | Not checked |
| KVKK / BDDK control mapping | Documented | Not checked | Not checked | Not checked |
| Inline redaction | Yes | Varies | Varies | Only on traffic it can decrypt |
| Reversible tokenization | Yes | Varies | Varies | Varies |
| Hash-chained audit log | Admin actions | Not checked | Not checked | Not checked |
| Published bypasses | Yes | Not checked | Not checked | Not checked |
| Open source | AGPL-3.0 | Mostly no | Yes, licence varies | Mostly no |
The three columns on the right describe a class of product, not a product. No product or version was tested for this table; "Not checked" means that.
Tamga is a security layer, not a general LLM gateway. If you mainly need routing, load balancing and semantic caching across many providers, a dedicated gateway does that better; Tamga can sit in front of or behind one. More in docs/comparison.md.
version: "1.0"
name: "default-policy"
rules:
pii_detection: # contact-style PII is redacted
action: REDACT
sensitivity: medium
types: [iban, email, phone_tr, phone, vkn, ip_public]
pii: # critical identifiers are blocked
action: BLOCK
sensitivity: medium
types: [tc_kimlik, credit_card, ssn]
secret_detection:
action: BLOCK
sensitivity: low
injection:
action: BLOCK
sensitivity: medium
vault: # reversible tokenization instead of masking
enabled: false
canary: # system-prompt leak detection
enabled: false
block_on_leak: true
providers:
allowed: [openai, anthropic, azure, gemini]
rate_limit:
max_requests_per_minute: 60
max_tokens_per_day: 500000
action_on_exceed: BLOCK
The shipped policy is proxy/tamga-policy.yaml. An unknown or misplaced key is a load error, not a silent no-op.
| Variable | Default | Purpose |
|---|---|---|
TAMGA_PROXY_PORT | 8443 | Listen port (proxy and management API) |
TAMGA_POLICY_PATH | ./tamga-policy.yaml | Policy file. The default is relative to the proxy's working directory; the file in this repository is proxy/tamga-policy.yaml |
TAMGA_ADMIN_KEY | — | Key for the management API |
TAMGA_DB_URL | — | PostgreSQL DSN; empty disables persistence |
REDIS_URL | — | Rate limiting, cache, vault store |
TAMGA_MOCK_UPSTREAM | false | Answer with a canned response instead of calling a provider |
TAMGA_VAULT_KEY | — | Base64 32-byte AES key for vault entries at rest |
TAMGA_FINDING_HASH_KEY | — | Base64 key (32 bytes or more) for the hashes of finding matches in the request log (data.hash_findings); empty derives one from TAMGA_VAULT_KEY |
TAMGA_TRUST_ROLE_HEADER | false | Honour X-Tamga-Role for policy exceptions; only behind an authenticating gateway |
TAMGA_REQUIRE_KEY | false | Refuse proxy requests that carry no valid X-Tamga-Key |
TAMGA_CLASSIFIER_ADDR | — | gRPC address of the optional classifier service; the policy's scan.classifier turns it on |
TAMGA_UPSTREAM_ALLOW | — | Hosts a policy's provider pool may point at beyond the vendors' own |
TAMGA_ALERT_ALLOW_PRIVATE | — | Internal receivers alerts may be sent to (CIDR ranges, exact host names). Empty: internal addresses are refused |
TAMGA_TRUSTED_PROXIES | — | IPs / CIDR ranges of the load balancers allowed to set X-Forwarded-For. Empty: the header is ignored |
TAMGA_SCANNER_SERVICE_ADDR | — | Delegate stateless scanners to a scanner-service over gRPC |
TAMGA_RETENTION_REQUEST_LOGS_DAYS | 30 | How long request metadata is kept |
Full list: docs/operations.md · proxy/README.md
helm install tamga ./deploy/helm/tamga-proxy -n security --create-namespace
No public container image is published yet: build one from proxy/Dockerfile,
push it to your registry, and set image.repository in values.yaml.
The chart has not been tested: nothing in this repository lints, renders or installs it, and its version fields are behind the release. Read the templates before you rely on it.
What is stored. Request metadata and masked findings, for 30 days by default. Prompt and response bodies are not written to the database.
| Architecture | Deployment topology, request lifecycle, scanner pipeline, schema |
| Operations | Configuration, vault, canary tokens, budgets, management API |
| Compliance | KVKK, BDDK, GDPR and OWASP LLM Top 10 mappings |
| Benchmarks | Method, corpus and per-category results |
| Development | Repository layout, local commands, tech stack |
| Writing a scanner | Stateful scanners with request context |
| Migrating from LLM Guard | Scanner by scanner: what Tamga replaces, in part or not at all |
| LiteLLM guardrail | Adding Tamga to a LiteLLM proxy as a guardrail, without its proxy in the path |
| Browser extension | Checking what is pasted or sent on three chat sites against your own Tamga |
| Policy templates | Starting-point policies for KVKK, banks, public bodies and software teams. Not legal advice |
| Threat model | Trust boundaries, adversaries, the control for each with its code and test, and what remains |
| Verifying a release | Checking signatures, provenance and SBOMs. No release has been made yet |
| Kurulum ve işletim (Türkçe) | Installation and operations guide in Turkish, for the Compose stack |
| jugeni integration | Operator-state decision governance |
| Use cases · FAQ | Where it fits, common questions |
Pre-1.0 and maintained by one person. Use it, test it against your own traffic, and expect rough edges.
Works today, each with a test that runs the whole path against a stand-in
for the other side: inline PII, secret and injection scanning · YAML policy
with hot reload · the OpenAI, Anthropic and Gemini routes · role-aware
scanning of tool results, and STRIP · the optional inline classifier ·
per-key rate limits · a daily token and cost cap per organisation · response
cache · the Postgres request
log · POST /api/v1/scan · the endpoint a LiteLLM proxy calls as a
guardrail (LiteLLM) · Docker Compose.
None of it has been run against a real provider or a real third-party service as part of the tests: the other side is always a stand-in. The one exception is LiteLLM: a CI job runs a real LiteLLM container (v1.104.2) against the endpoint, in front of a fake model.
In the repository, not tested end to end. The code is there; nothing exercises the whole path, so treat these as experimental:
/azure, /bedrock, /mistral and /local routes. No test forwards a
request through them; the Bedrock signature is checked only for its headers.The audit log's hash chain covers administrative actions. Request decisions are stored in the request log, which is not chained.
On main, not yet released (v0.9.0): vault · canary tokens · trend graphs ·
custom entity UI · the security fixes listed in the changelog.
Known limits:
TAMGA_REQUIRE_KEY=true. See SECURITY.md.output_rules.streaming on, and
then only the model's text: tool-call arguments and thinking pass unread.Planned next, in this order, with no dates and no promise: a locked evaluation set for the scanners and the classifier · response scanners for malicious links and for JSON that does not match a schema · the browser extension checked against the live chat sites. MCP inspection is not in the plan.
Issues and pull requests are welcome. Start with
CONTRIBUTING.md; cd proxy && go test ./... runs the suite,
and the Postgres integration tests need Docker.
Found a way past a scanner? That is exactly what the adversarial suite is for — open an issue or send the vector as a pull request. For anything exploitable, follow SECURITY.md instead.
Tamga is free software under the GNU Affero General Public License, version 3 (AGPL-3.0). That is the only licence and this repository is the only edition: nothing is held back for a paid one.
You may run, study, change and share it under that licence. If you change it and let people use your version over a network, section 13 requires you to offer those users the source code of your version, at no charge. The licence text is what applies; this paragraph only points at it.
45 followers · starred Jul 2026
Self-hosted LLM security proxy. PII redaction, prompt injection defense, KVKK/GDPR/PCI-DSS compliance. Sub-millisecond latency
See the codeA guardrail for LLM traffic that you run on your own servers.
Tamga sits in front of the model APIs your applications, RAG pipelines and
agents call. It scans each request routed through it and blocks or redacts
PII, secrets and prompt injection before the request is forwarded to the provider.
Website · Quick start · How it works · Benchmarks · Docs · Status
An application that calls a model API forwards whatever it is handed. A support bot passes on a ticket with the customer's national ID in it, and a RAG pipeline retrieves a document that holds an IBAN or an API key. An agent reads a web page that carries instructions written for the model, and that text arrives in its next request as a tool result. The usual controls do not help:
Tamga is a reverse proxy you run yourself. It speaks the OpenAI and Anthropic
APIs, so applications only change their base URL. It reads each request
message by message and knows whether a piece of text is the user's prompt, a
tool result, a tool description or the system prompt. Scanners look for PII,
secrets and injection wording in that text, and a YAML policy turns each
finding into a block, a redaction, a warning or a pass. A rule can be limited
to some of those roles, and a tool result that carries an injection can be
stripped so the agent's session continues. Responses are scanned too when the
policy's output_rules are on. Every decision is recorded in your own
Postgres. Built for KVKK, GDPR and
BDDK workloads, with first-class Turkish PII (TC Kimlik, Turkish IBAN and phone formats).
Tamga sees only the requests that are sent to it. It does not see:
More limits are listed under Project status.
Start with the proxy alone. It needs Docker and nothing else: no database, no keys.
git clone https://github.com/yatuk/tamga.git
cd tamga
cp .env.example .env # set TAMGA_MOCK_UPSTREAM=true to try it without provider keys
docker compose --env-file .env -f deploy/docker-compose.yml --profile minimal up -d
The proxy is on http://localhost:8443. Send a prompt carrying a Turkish
national ID. It is blocked before it reaches any provider:
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8443/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Müşteri TC 10000000146"}]}'
# 403
The proxy alone scans, applies the policy and forwards, but keeps no record: there is no request log, no dashboard, and API keys and settings made through the API are lost on restart. What it does and does not do is listed in docs/operations.md.
The full stack adds the request log in Postgres, the dashboard, the analyzer (PDF reports), Redis, NATS and Jaeger. Stop the proxy-only container first, as both use port 8443:
docker compose --env-file .env -f deploy/docker-compose.yml --profile minimal down
docker compose --env-file .env -f deploy/docker-compose.yml up -d
.env.example sets COMPOSE_PROFILES=full, so this starts the full stack;
--profile full does the same. Proxy on http://localhost:8443, dashboard on
http://localhost:3000. The dashboard holds no key: on first use it asks for
the admin key (TAMGA_ADMIN_KEY from .env) under Settings and keeps it in
that browser.
--env-file .envis required. Compose only auto-loads a.envnext to the compose file, so without it the database password is empty, Postgres will not start, andCOMPOSE_PROFILESis not read. An.envmade before 0.9.0 has noCOMPOSE_PROFILESline: addCOMPOSE_PROFILES=fullto it.
Point an existing SDK at the proxy:
import os
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8443/v1", # was https://api.openai.com/v1
api_key=os.environ["OPENAI_API_KEY"],
)
Provider routes: /v1 (OpenAI), /anthropic, /gemini, /azure, /bedrock,
/mistral, /local. The default policy allows the first four. Only the first
three are tested by forwarding a request to a stand-in provider; see
Project status.
flowchart LR
A[Your app] -->|OpenAI / Anthropic API| B[Tamga proxy]
B --> C[Scanners]
C --> D{Policy}
D -->|pass| E[LLM provider]
D -->|redact| E
D -->|block| F[403 + audit event]
E -->|response| G[Output scan]
G --> A
BLOCK, REDACT, WARN or
PASS. It hot-reloads, is parsed strictly, and warns at startup if nothing
acts on PII, secrets or injection.output_rules are on; they are
off in the shipped policy.notify: URL of the rule that matched.Diagrams for the request lifecycle, scanner pipeline, policy flow and database schema are in docs/architecture.
| Detects | Default action | |
|---|---|---|
| PII | TC Kimlik, credit cards (Luhn + BIN), IBAN, email, phone, IP | Block IDs and cards, redact the rest |
| Secrets | AWS, Azure storage, GitHub, GitLab, OpenAI, Anthropic, Stripe, Slack, npm and PyPI keys and tokens, JWTs, private keys, connection strings, credentials in URLs (all formats) | Block |
| Prompt injection | Instruction override, role manipulation, jailbreak patterns, encoded payloads, text hidden in invisible characters; EN/TR/DE | Block |
| Custom entities | Your own regex patterns (customer numbers, file IDs), editable from the dashboard | Per entity |
| System-prompt leaks | Canary token injected into the system prompt and looked for in the response | Block (opt-in) |
| Decision governance | Prompts that contradict a locked decision in a jugeni audit log | Per assertion (opt-in) |
Also in the box: reversible PII tokenization (vault), per-key rate limits, a daily budget per organisation, provider allowlists, a response cache, and a SOC dashboard with an incident queue. See docs/operations.md, and docs/agents.md for running Claude Code and other agents through it.
Six pages of the dashboard, on made-up data (how the pictures are taken):
Overview: what the proxy scanned in the last 7 days, what it found and what it stopped most recently.
Incidents: the requests the proxy acted on, newest first, with the proxy's own counts for the week.
Event explorer: every scanned request, with one stopped request open to show what was found and where.
Playground: a prompt tried against the running policy, with the text behind the verdict marked.
Policies: the policy document in the editor, saved and applied without a restart.
Traffic: how many requests went through, by day, by provider and by model family.
Measured on 2026-10-03. Everything here is reproducible from this repository.
Accuracy, as precision / recall, on two sets of prompts written for this
repository. The inline classifier is optional: a local model asked about what
the rules did not block, here
prompt-injection-guard-small
at threshold 0.98.
| Set | Prompts | Deterministic scanners alone | With the inline classifier |
|---|---|---|---|
Tuning corpus prompts.csv (gates CI) | 309, of which 117 benign | 1.000 / 0.896 | 1.000 / 0.927 |
Held-out set holdout.csv | 163, of which 85 benign | 1.000 / 1.000 | 1.000 / 1.000 |
On 2026-10-11 the tuning corpus grew by 22 prompts written together with new detectors (331, of which 121 benign). The deterministic scanners score 1.000 / 0.905 on it: the 18 added attacks are caught and the 20 misses are the same 20. A second change that day (translation requests no longer blocked, a URL's user and password no longer read as an e-mail address, instructions spelled one letter at a time read) added 30 more (361, of which 138 benign; the held-out set got 6 benign translation requests, 169 of which 91 benign): 1.000 / 0.910 and 1.000 / 1.000, with the same 20 misses. The classifier has not been run on the larger corpus.
Source: docs/benchmarks/README.md, from the reports redteam_latest.json, redteam_holdout.json, redteam_classifier_latest.json and redteam_classifier_holdout.json.
Read that honestly. The rules were written against the misses in the tuning corpus, so its recall says how well they fit it, not how they do on new traffic; on 2026-10-01, before that work, the same corpus scored 0.969 / 0.495. The held-out set was written separately but by the same author, in the same attack families, so it overstates too. Expect less on your own traffic and measure there. What the rules still miss in the corpus is semantic: fictional and hypothetical framings, data-exfiltration requests, health data, street addresses.
What the classifier added. On the tuning corpus it caught 6 attacks the rules missed and flagged no benign sample. On the held-out set it added nothing: the rules already caught every attack. The threshold was chosen on the tuning corpus as the lowest that flagged no benign sample, then run unchanged on the held-out set. The gain is small because both sets were built around what rules can catch; they are the wrong instrument for a model whose purpose is phrasing nobody anticipated, and no independent set has been run yet. A classifier call took p50 14–24 ms and p95 23–58 ms across runs on the machine below; text seen in the last ten minutes is answered from memory. At threshold 0.9 the classifier catches two more attacks in the tuning corpus and flags 1 of its 117 benign samples, and 2 of the 85 in the held-out set. It did not catch the three injection vectors that bypass the adversarial suite.
Latency. Scan stage: p50 0.2 ms, p95 0.7–0.8 ms. End to end through the Docker Compose stack with a mocked upstream:
| Load | P50 | P95 | P99 | Errors |
|---|---|---|---|---|
| 100 RPS | 2.6 ms | 4.0 ms | 5.4 ms | 0% |
| 500 RPS | 1.6 ms | 3.3 ms | 6.6 ms | 0% |
| 1000 RPS | 1.6 ms | 5.0 ms | 9.9 ms | 0% |
16-core laptop CPU, Docker limited to 12 GB. The medians repeat from run to run; the tails do not: runs on this day gave a P95 at 1000 RPS anywhere from 3.0 to 5.0 ms. These also depend on hardware: an earlier run on a 4-core machine held the same P95 to 500 RPS and reached 130 ms at 1000. The prompts are chat-sized; for large requests see docs/agents.md.
Adversarial suite. 84 attack vectors written to evade the scanners: 78 detected, 6 bypass. Nineteen of them attack the structure of the request rather than its wording: text in JSON escapes, in tool results, tool call arguments, tool descriptions and attachments, and bodies that parsers read differently. The bypasses are published in tests/stress/baseline.json and include leetspeak and character-by-character smuggling. That is the run of 2026-10-03: since 2026-10-11 the scanners read the two character-by-character vectors and the leetspeak one (measured in-process, docs/benchmarks, leetspeak), and the suite has not been run again, so the baseline still lists them. The vectors are known to the authors, so this is a regression suite, not an independent evaluation. With the classifier on, the result was the same 78 and 6, and the load figures were within the run-to-run spread of the table above (the load test repeats its prompts, so it exercises the classifier's cache, not the model). No result file for that run is in the repository; the baseline describes the run without the classifier.
cd proxy && go run ./cmd/redteam -in ./testdata/redteam/prompts.csv # accuracy
./tests/stress/run_stress_suite.sh # adversarial + load
Method and per-category results: docs/benchmarks · tests/stress
| Tamga | Hosted gateways | OSS gateways | Network DLP | |
|---|---|---|---|---|
| Runs entirely on your infrastructure | Yes | No | Yes | Usually |
| Turkish PII (TCKN, IBAN, phone) | Native | Not checked | Not checked | Not checked |
| KVKK / BDDK control mapping | Documented | Not checked | Not checked | Not checked |
| Inline redaction | Yes | Varies | Varies | Only on traffic it can decrypt |
| Reversible tokenization | Yes | Varies | Varies | Varies |
| Hash-chained audit log | Admin actions | Not checked | Not checked | Not checked |
| Published bypasses | Yes | Not checked | Not checked | Not checked |
| Open source | AGPL-3.0 | Mostly no | Yes, licence varies | Mostly no |
The three columns on the right describe a class of product, not a product. No product or version was tested for this table; "Not checked" means that.
Tamga is a security layer, not a general LLM gateway. If you mainly need routing, load balancing and semantic caching across many providers, a dedicated gateway does that better; Tamga can sit in front of or behind one. More in docs/comparison.md.
version: "1.0"
name: "default-policy"
rules:
pii_detection: # contact-style PII is redacted
action: REDACT
sensitivity: medium
types: [iban, email, phone_tr, phone, vkn, ip_public]
pii: # critical identifiers are blocked
action: BLOCK
sensitivity: medium
types: [tc_kimlik, credit_card, ssn]
secret_detection:
action: BLOCK
sensitivity: low
injection:
action: BLOCK
sensitivity: medium
vault: # reversible tokenization instead of masking
enabled: false
canary: # system-prompt leak detection
enabled: false
block_on_leak: true
providers:
allowed: [openai, anthropic, azure, gemini]
rate_limit:
max_requests_per_minute: 60
max_tokens_per_day: 500000
action_on_exceed: BLOCK
The shipped policy is proxy/tamga-policy.yaml. An unknown or misplaced key is a load error, not a silent no-op.
| Variable | Default | Purpose |
|---|---|---|
TAMGA_PROXY_PORT | 8443 | Listen port (proxy and management API) |
TAMGA_POLICY_PATH | ./tamga-policy.yaml | Policy file. The default is relative to the proxy's working directory; the file in this repository is proxy/tamga-policy.yaml |
TAMGA_ADMIN_KEY | — | Key for the management API |
TAMGA_DB_URL | — | PostgreSQL DSN; empty disables persistence |
REDIS_URL | — | Rate limiting, cache, vault store |
TAMGA_MOCK_UPSTREAM | false | Answer with a canned response instead of calling a provider |
TAMGA_VAULT_KEY | — | Base64 32-byte AES key for vault entries at rest |
TAMGA_FINDING_HASH_KEY | — | Base64 key (32 bytes or more) for the hashes of finding matches in the request log (data.hash_findings); empty derives one from TAMGA_VAULT_KEY |
TAMGA_TRUST_ROLE_HEADER | false | Honour X-Tamga-Role for policy exceptions; only behind an authenticating gateway |
TAMGA_REQUIRE_KEY | false | Refuse proxy requests that carry no valid X-Tamga-Key |
TAMGA_CLASSIFIER_ADDR | — | gRPC address of the optional classifier service; the policy's scan.classifier turns it on |
TAMGA_UPSTREAM_ALLOW | — | Hosts a policy's provider pool may point at beyond the vendors' own |
TAMGA_ALERT_ALLOW_PRIVATE | — | Internal receivers alerts may be sent to (CIDR ranges, exact host names). Empty: internal addresses are refused |
TAMGA_TRUSTED_PROXIES | — | IPs / CIDR ranges of the load balancers allowed to set X-Forwarded-For. Empty: the header is ignored |
TAMGA_SCANNER_SERVICE_ADDR | — | Delegate stateless scanners to a scanner-service over gRPC |
TAMGA_RETENTION_REQUEST_LOGS_DAYS | 30 | How long request metadata is kept |
Full list: docs/operations.md · proxy/README.md
helm install tamga ./deploy/helm/tamga-proxy -n security --create-namespace
No public container image is published yet: build one from proxy/Dockerfile,
push it to your registry, and set image.repository in values.yaml.
The chart has not been tested: nothing in this repository lints, renders or installs it, and its version fields are behind the release. Read the templates before you rely on it.
What is stored. Request metadata and masked findings, for 30 days by default. Prompt and response bodies are not written to the database.
| Architecture | Deployment topology, request lifecycle, scanner pipeline, schema |
| Operations | Configuration, vault, canary tokens, budgets, management API |
| Compliance | KVKK, BDDK, GDPR and OWASP LLM Top 10 mappings |
| Benchmarks | Method, corpus and per-category results |
| Development | Repository layout, local commands, tech stack |
| Writing a scanner | Stateful scanners with request context |
| Migrating from LLM Guard | Scanner by scanner: what Tamga replaces, in part or not at all |
| LiteLLM guardrail | Adding Tamga to a LiteLLM proxy as a guardrail, without its proxy in the path |
| Browser extension | Checking what is pasted or sent on three chat sites against your own Tamga |
| Policy templates | Starting-point policies for KVKK, banks, public bodies and software teams. Not legal advice |
| Threat model | Trust boundaries, adversaries, the control for each with its code and test, and what remains |
| Verifying a release | Checking signatures, provenance and SBOMs. No release has been made yet |
| Kurulum ve işletim (Türkçe) | Installation and operations guide in Turkish, for the Compose stack |
| jugeni integration | Operator-state decision governance |
| Use cases · FAQ | Where it fits, common questions |
Pre-1.0 and maintained by one person. Use it, test it against your own traffic, and expect rough edges.
Works today, each with a test that runs the whole path against a stand-in
for the other side: inline PII, secret and injection scanning · YAML policy
with hot reload · the OpenAI, Anthropic and Gemini routes · role-aware
scanning of tool results, and STRIP · the optional inline classifier ·
per-key rate limits · a daily token and cost cap per organisation · response
cache · the Postgres request
log · POST /api/v1/scan · the endpoint a LiteLLM proxy calls as a
guardrail (LiteLLM) · Docker Compose.
None of it has been run against a real provider or a real third-party service as part of the tests: the other side is always a stand-in. The one exception is LiteLLM: a CI job runs a real LiteLLM container (v1.104.2) against the endpoint, in front of a fake model.
In the repository, not tested end to end. The code is there; nothing exercises the whole path, so treat these as experimental:
/azure, /bedrock, /mistral and /local routes. No test forwards a
request through them; the Bedrock signature is checked only for its headers.The audit log's hash chain covers administrative actions. Request decisions are stored in the request log, which is not chained.
On main, not yet released (v0.9.0): vault · canary tokens · trend graphs ·
custom entity UI · the security fixes listed in the changelog.
Known limits:
TAMGA_REQUIRE_KEY=true. See SECURITY.md.output_rules.streaming on, and
then only the model's text: tool-call arguments and thinking pass unread.Planned next, in this order, with no dates and no promise: a locked evaluation set for the scanners and the classifier · response scanners for malicious links and for JSON that does not match a schema · the browser extension checked against the live chat sites. MCP inspection is not in the plan.
Issues and pull requests are welcome. Start with
CONTRIBUTING.md; cd proxy && go test ./... runs the suite,
and the Postgres integration tests need Docker.
Found a way past a scanner? That is exactly what the adversarial suite is for — open an issue or send the vector as a pull request. For anything exploitable, follow SECURITY.md instead.
Tamga is free software under the GNU Affero General Public License, version 3 (AGPL-3.0). That is the only licence and this repository is the only edition: nothing is held back for a paid one.
You may run, study, change and share it under that licence. If you change it and let people use your version over a network, section 13 requires you to offer those users the source code of your version, at no charge. The licence text is what applies; this paragraph only points at it.
45 followers · starred Jul 2026