yatuk/tamga

Self-hosted LLM security proxy. PII redaction, prompt injection defense, KVKK/GDPR/PCI-DSS compliance. Sub-millisecond latency

Go

39

271 commits

updated Oct 12, 2026

See the code

See what people are saying

SourceMessageScoreDate

Indirect prompt injection demo: the README looks harmless, the model sees a hidden instruction (r/LLMDevs)

Around the 30 second mark: an agent reads a README. Rendered, it’s a normal setup guide. Raw, there’s an HTML comment telling the assistant to ignore its guidelines. Tamga scans tool results on the next request and blocks it with a 403 before the model reads it. You can also set it to strip just…

1

Oct 12, 2026

README

Tamga

Tamga

A 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.

Tests Version License Go Stars

Website · Quick start · How it works · Benchmarks · Docs · Status

Four requests through Tamga: a clean prompt passes, an email address is redacted and forwarded, a card number and a prompt injection are blocked before they reach the provider

Why Tamga

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:

  • Network DLP sees an encrypted request to an API, not what the prompt says.
  • Hosted LLM gateways inspect your prompts on their servers, which is the data transfer you were trying to avoid.
  • Provider guardrails cover one vendor and leave no audit trail of your own.

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).

What Tamga does not cover

Tamga sees only the requests that are sent to it. It does not see:

  • Chat websites. Text someone types or pastes into chatgpt.com, claude.ai or gemini.google.com in a browser goes from the browser to that site and never reaches the proxy. A browser extension for those three sites exists as an MVP: it asks your Tamga for a verdict at paste and at send. It has not been verified on the live sites, it is not in any store (you load or pack it yourself), and it covers neither file uploads nor the sites' apps.
  • Traffic between an agent and its MCP servers. The agent talks to them directly. What a tool returned reaches Tamga only when the agent puts it in its next model request, as a tool result.
  • Anything else that does not pass through the proxy. An application that still calls the provider's own URL is not scanned. Tamga does not discover or stop such calls.
  • Images, audio and PDFs. They are forwarded without being read. An attached text file is decoded and scanned.

More limits are listed under Project status.

Quick start

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 .env is required. Compose only auto-loads a .env next to the compose file, so without it the database password is empty, Postgres will not start, and COMPOSE_PROFILES is not read. An .env made before 0.9.0 has no COMPOSE_PROFILES line: add COMPOSE_PROFILES=full to 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.

How it works

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
  1. Scan. Deterministic scanners run in-process on every request: an Aho-Corasick automaton plus validators (Luhn, TCKN and IBAN checksums) and Unicode normalization against homoglyph and zero-width evasion. Optionally, a local model then reads what the rules did not block and flags injection by meaning rather than wording (inline classifier).
  2. Decide. A YAML policy maps findings to BLOCK, REDACT, WARN or PASS. It hot-reloads, is parsed strictly, and warns at startup if nothing acts on PII, secrets or injection.
  3. Forward. Clean or redacted requests go to the provider. Responses are scanned on the way back when the policy's output_rules are on; they are off in the shipped policy.
  4. Record. Every decision becomes an event in the Postgres request log and the dashboard (full stack only; the proxy alone keeps the last 1000 in memory). A blocked, stripped or warned request is also posted to the alert destinations you have connected, and to the notify: URL of the rule that matched.

Diagrams for the request lifecycle, scanner pipeline, policy flow and database schema are in docs/architecture.

What it catches

DetectsDefault action
PIITC Kimlik, credit cards (Luhn + BIN), IBAN, email, phone, IPBlock IDs and cards, redact the rest
SecretsAWS, 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 injectionInstruction override, role manipulation, jailbreak patterns, encoded payloads, text hidden in invisible characters; EN/TR/DEBlock
Custom entitiesYour own regex patterns (customer numbers, file IDs), editable from the dashboardPer entity
System-prompt leaksCanary token injected into the system prompt and looked for in the responseBlock (opt-in)
Decision governancePrompts that contradict a locked decision in a jugeni audit logPer 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):

The Overview page: 214 of 18,412 requests stopped in the last 7 days, a table of what was found by kind, the seven requests stopped most recently, and a column chart of requests by day
Overview: what the proxy scanned in the last 7 days, what it found and what it stopped most recently.

The Incidents page: the proxy's counts of stopped, redacted and warned requests in the last 7 days above a table of the latest 200, each row with its time, action, finding, where it was found, provider and model
Incidents: the requests the proxy acted on, newest first, with the proxy's own counts for the week.

The Event explorer with one stopped request open beside the list: an instruction override and an email address found in a tool result, each with its location and masked match, the triage buttons, and the request's ID, endpoint and policy version
Event explorer: every scanned request, with one stopped request open to show what was found and where.

The Playground: a prompt beside the running policy, the verdict Stopped, the prompt with an email address and an injected instruction marked in it, and a table of the two findings
Playground: a prompt tried against the running policy, with the text behind the verdict marked.

The Policies page: the policy document open in a code editor, with tabs for changes, history and custom entities, and buttons to reload it from disk or save and apply it
Policies: the policy document in the editor, saved and applied without a restart.

The Traffic page: 18,412 requests in the last 7 days, a column chart of requests by day, and tables of requests by provider and by model family
Traffic: how many requests went through, by day, by provider and by model family.

Benchmarks

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.

SetPromptsDeterministic scanners aloneWith the inline classifier
Tuning corpus prompts.csv (gates CI)309, of which 117 benign1.000 / 0.8961.000 / 0.927
Held-out set holdout.csv163, of which 85 benign1.000 / 1.0001.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:

LoadP50P95P99Errors
100 RPS2.6 ms4.0 ms5.4 ms0%
500 RPS1.6 ms3.3 ms6.6 ms0%
1000 RPS1.6 ms5.0 ms9.9 ms0%

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

How it compares

TamgaHosted gatewaysOSS gatewaysNetwork DLP
Runs entirely on your infrastructureYesNoYesUsually
Turkish PII (TCKN, IBAN, phone)NativeNot checkedNot checkedNot checked
KVKK / BDDK control mappingDocumentedNot checkedNot checkedNot checked
Inline redactionYesVariesVariesOnly on traffic it can decrypt
Reversible tokenizationYesVariesVariesVaries
Hash-chained audit logAdmin actionsNot checkedNot checkedNot checked
Published bypassesYesNot checkedNot checkedNot checked
Open sourceAGPL-3.0Mostly noYes, licence variesMostly 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.

Configuration

Policy — one YAML file, hot-reloaded
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.

Environment variables
VariableDefaultPurpose
TAMGA_PROXY_PORT8443Listen port (proxy and management API)
TAMGA_POLICY_PATH./tamga-policy.yamlPolicy 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_UPSTREAMfalseAnswer 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_HEADERfalseHonour X-Tamga-Role for policy exceptions; only behind an authenticating gateway
TAMGA_REQUIRE_KEYfalseRefuse 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_DAYS30How long request metadata is kept

Full list: docs/operations.md · proxy/README.md

Kubernetes
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.

Documentation

ArchitectureDeployment topology, request lifecycle, scanner pipeline, schema
OperationsConfiguration, vault, canary tokens, budgets, management API
ComplianceKVKK, BDDK, GDPR and OWASP LLM Top 10 mappings
BenchmarksMethod, corpus and per-category results
DevelopmentRepository layout, local commands, tech stack
Writing a scannerStateful scanners with request context
Migrating from LLM GuardScanner by scanner: what Tamga replaces, in part or not at all
LiteLLM guardrailAdding Tamga to a LiteLLM proxy as a guardrail, without its proxy in the path
Browser extensionChecking what is pasted or sent on three chat sites against your own Tamga
Policy templatesStarting-point policies for KVKK, banks, public bodies and software teams. Not legal advice
Threat modelTrust boundaries, adversaries, the control for each with its code and test, and what remains
Verifying a releaseChecking 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 integrationOperator-state decision governance
Use cases · FAQWhere it fits, common questions

Project status

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:

  • The /azure, /bedrock, /mistral and /local routes. No test forwards a request through them; the Bedrock signature is checked only for its headers.
  • Alert destinations (Slack, Teams, Splunk, Sentinel, QRadar, Datadog, Jira, PagerDuty, Opsgenie, ServiceNow). Decisions are delivered to them, each in its own format, and that is tested against local stand-ins. None has been tried against the real service, and several need their authentication header added by hand (alerts).
  • Streamed-response scanning outside OpenAI chat completions. The Anthropic, Gemini and OpenAI Responses stream formats are tested at parsing level only.
  • The Helm chart and the Terraform module (never linted, rendered or applied).
  • The Python and TypeScript SDKs (unit tests with a mocked transport, not run in CI).
  • The analyzer (its tests mock Presidio and the LLM judge).
  • Redis, NATS and OpenTelemetry export: tested with in-memory or mocked substitutes, not against the real servers.
  • mTLS as the proxy sets it up, the remote scanner service, and SCIM (no tests at all).
  • The dashboard against a real proxy: the browser tests in CI answer the API themselves; the suite that drives a real proxy is run by hand.

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:

  • The rules match wording. An attack phrased in a way no rule anticipates needs the optional classifier, and that has not been measured on a set its author did not write (see Benchmarks).
  • Images, audio and PDFs are not read.
  • Callers are authenticated only when you issue keys; requests without one are refused only with TAMGA_REQUIRE_KEY=true. See SECURITY.md.
  • Streamed responses are scanned only with output_rules.streaming on, and then only the model's text: tool-call arguments and thinking pass unread.
  • The first scan of a very large request is slow, about 250 KB of new text a second per core (agents).
  • Traffic from a browser to a chat site, and between an agent and its MCP servers, does not pass through a proxy at all. For three chat sites there is a browser extension, an MVP whose site selectors are unverified.
  • There is no multi-region replication, no SSO sign-in beyond GitHub OAuth (SSO settings are stored and used by no sign-in; the SCIM routes have no store behind them) and no custom roles.

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.

  • jugeni by Mike Czerwiński — an operator-state framework. Tamga reads its audit log to enforce locked decisions before a call is made.
  • MCPRadar — a pre-deployment security scanner for MCP servers. Static analysis before you deploy; Tamga at runtime.

Contributing

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.

License

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.


© 2026 Fatih Serdar Çakmak · tamgaproxy.com
ai-gateway
ai-security
anthropic-proxy
compliance
data-loss-prevention
gdpr
go
guardrails
kvkk
llm-firewall
llm-firewalls
llm-security
openai-proxy
pci-dss
pci-dss-compliance
pii-redaction
prompt-injection
reverse-proxy-server
self-hosted

yatuk/tamga

Self-hosted LLM security proxy. PII redaction, prompt injection defense, KVKK/GDPR/PCI-DSS compliance. Sub-millisecond latency

Go

39

271 commits

updated Oct 12, 2026

See the code

See what people are saying

SourceMessageScoreDate

Indirect prompt injection demo: the README looks harmless, the model sees a hidden instruction (r/LLMDevs)

Around the 30 second mark: an agent reads a README. Rendered, it’s a normal setup guide. Raw, there’s an HTML comment telling the assistant to ignore its guidelines. Tamga scans tool results on the next request and blocks it with a 403 before the model reads it. You can also set it to strip just…

1

Oct 12, 2026

README

Tamga

Tamga

A 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.

Tests Version License Go Stars

Website · Quick start · How it works · Benchmarks · Docs · Status

Four requests through Tamga: a clean prompt passes, an email address is redacted and forwarded, a card number and a prompt injection are blocked before they reach the provider

Why Tamga

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:

  • Network DLP sees an encrypted request to an API, not what the prompt says.
  • Hosted LLM gateways inspect your prompts on their servers, which is the data transfer you were trying to avoid.
  • Provider guardrails cover one vendor and leave no audit trail of your own.

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).

What Tamga does not cover

Tamga sees only the requests that are sent to it. It does not see:

  • Chat websites. Text someone types or pastes into chatgpt.com, claude.ai or gemini.google.com in a browser goes from the browser to that site and never reaches the proxy. A browser extension for those three sites exists as an MVP: it asks your Tamga for a verdict at paste and at send. It has not been verified on the live sites, it is not in any store (you load or pack it yourself), and it covers neither file uploads nor the sites' apps.
  • Traffic between an agent and its MCP servers. The agent talks to them directly. What a tool returned reaches Tamga only when the agent puts it in its next model request, as a tool result.
  • Anything else that does not pass through the proxy. An application that still calls the provider's own URL is not scanned. Tamga does not discover or stop such calls.
  • Images, audio and PDFs. They are forwarded without being read. An attached text file is decoded and scanned.

More limits are listed under Project status.

Quick start

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 .env is required. Compose only auto-loads a .env next to the compose file, so without it the database password is empty, Postgres will not start, and COMPOSE_PROFILES is not read. An .env made before 0.9.0 has no COMPOSE_PROFILES line: add COMPOSE_PROFILES=full to 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.

How it works

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
  1. Scan. Deterministic scanners run in-process on every request: an Aho-Corasick automaton plus validators (Luhn, TCKN and IBAN checksums) and Unicode normalization against homoglyph and zero-width evasion. Optionally, a local model then reads what the rules did not block and flags injection by meaning rather than wording (inline classifier).
  2. Decide. A YAML policy maps findings to BLOCK, REDACT, WARN or PASS. It hot-reloads, is parsed strictly, and warns at startup if nothing acts on PII, secrets or injection.
  3. Forward. Clean or redacted requests go to the provider. Responses are scanned on the way back when the policy's output_rules are on; they are off in the shipped policy.
  4. Record. Every decision becomes an event in the Postgres request log and the dashboard (full stack only; the proxy alone keeps the last 1000 in memory). A blocked, stripped or warned request is also posted to the alert destinations you have connected, and to the notify: URL of the rule that matched.

Diagrams for the request lifecycle, scanner pipeline, policy flow and database schema are in docs/architecture.

What it catches

DetectsDefault action
PIITC Kimlik, credit cards (Luhn + BIN), IBAN, email, phone, IPBlock IDs and cards, redact the rest
SecretsAWS, 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 injectionInstruction override, role manipulation, jailbreak patterns, encoded payloads, text hidden in invisible characters; EN/TR/DEBlock
Custom entitiesYour own regex patterns (customer numbers, file IDs), editable from the dashboardPer entity
System-prompt leaksCanary token injected into the system prompt and looked for in the responseBlock (opt-in)
Decision governancePrompts that contradict a locked decision in a jugeni audit logPer 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):

The Overview page: 214 of 18,412 requests stopped in the last 7 days, a table of what was found by kind, the seven requests stopped most recently, and a column chart of requests by day
Overview: what the proxy scanned in the last 7 days, what it found and what it stopped most recently.

The Incidents page: the proxy's counts of stopped, redacted and warned requests in the last 7 days above a table of the latest 200, each row with its time, action, finding, where it was found, provider and model
Incidents: the requests the proxy acted on, newest first, with the proxy's own counts for the week.

The Event explorer with one stopped request open beside the list: an instruction override and an email address found in a tool result, each with its location and masked match, the triage buttons, and the request's ID, endpoint and policy version
Event explorer: every scanned request, with one stopped request open to show what was found and where.

The Playground: a prompt beside the running policy, the verdict Stopped, the prompt with an email address and an injected instruction marked in it, and a table of the two findings
Playground: a prompt tried against the running policy, with the text behind the verdict marked.

The Policies page: the policy document open in a code editor, with tabs for changes, history and custom entities, and buttons to reload it from disk or save and apply it
Policies: the policy document in the editor, saved and applied without a restart.

The Traffic page: 18,412 requests in the last 7 days, a column chart of requests by day, and tables of requests by provider and by model family
Traffic: how many requests went through, by day, by provider and by model family.

Benchmarks

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.

SetPromptsDeterministic scanners aloneWith the inline classifier
Tuning corpus prompts.csv (gates CI)309, of which 117 benign1.000 / 0.8961.000 / 0.927
Held-out set holdout.csv163, of which 85 benign1.000 / 1.0001.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:

LoadP50P95P99Errors
100 RPS2.6 ms4.0 ms5.4 ms0%
500 RPS1.6 ms3.3 ms6.6 ms0%
1000 RPS1.6 ms5.0 ms9.9 ms0%

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

How it compares

TamgaHosted gatewaysOSS gatewaysNetwork DLP
Runs entirely on your infrastructureYesNoYesUsually
Turkish PII (TCKN, IBAN, phone)NativeNot checkedNot checkedNot checked
KVKK / BDDK control mappingDocumentedNot checkedNot checkedNot checked
Inline redactionYesVariesVariesOnly on traffic it can decrypt
Reversible tokenizationYesVariesVariesVaries
Hash-chained audit logAdmin actionsNot checkedNot checkedNot checked
Published bypassesYesNot checkedNot checkedNot checked
Open sourceAGPL-3.0Mostly noYes, licence variesMostly 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.

Configuration

Policy — one YAML file, hot-reloaded
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.

Environment variables
VariableDefaultPurpose
TAMGA_PROXY_PORT8443Listen port (proxy and management API)
TAMGA_POLICY_PATH./tamga-policy.yamlPolicy 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_UPSTREAMfalseAnswer 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_HEADERfalseHonour X-Tamga-Role for policy exceptions; only behind an authenticating gateway
TAMGA_REQUIRE_KEYfalseRefuse 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_DAYS30How long request metadata is kept

Full list: docs/operations.md · proxy/README.md

Kubernetes
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.

Documentation

ArchitectureDeployment topology, request lifecycle, scanner pipeline, schema
OperationsConfiguration, vault, canary tokens, budgets, management API
ComplianceKVKK, BDDK, GDPR and OWASP LLM Top 10 mappings
BenchmarksMethod, corpus and per-category results
DevelopmentRepository layout, local commands, tech stack
Writing a scannerStateful scanners with request context
Migrating from LLM GuardScanner by scanner: what Tamga replaces, in part or not at all
LiteLLM guardrailAdding Tamga to a LiteLLM proxy as a guardrail, without its proxy in the path
Browser extensionChecking what is pasted or sent on three chat sites against your own Tamga
Policy templatesStarting-point policies for KVKK, banks, public bodies and software teams. Not legal advice
Threat modelTrust boundaries, adversaries, the control for each with its code and test, and what remains
Verifying a releaseChecking 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 integrationOperator-state decision governance
Use cases · FAQWhere it fits, common questions

Project status

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:

  • The /azure, /bedrock, /mistral and /local routes. No test forwards a request through them; the Bedrock signature is checked only for its headers.
  • Alert destinations (Slack, Teams, Splunk, Sentinel, QRadar, Datadog, Jira, PagerDuty, Opsgenie, ServiceNow). Decisions are delivered to them, each in its own format, and that is tested against local stand-ins. None has been tried against the real service, and several need their authentication header added by hand (alerts).
  • Streamed-response scanning outside OpenAI chat completions. The Anthropic, Gemini and OpenAI Responses stream formats are tested at parsing level only.
  • The Helm chart and the Terraform module (never linted, rendered or applied).
  • The Python and TypeScript SDKs (unit tests with a mocked transport, not run in CI).
  • The analyzer (its tests mock Presidio and the LLM judge).
  • Redis, NATS and OpenTelemetry export: tested with in-memory or mocked substitutes, not against the real servers.
  • mTLS as the proxy sets it up, the remote scanner service, and SCIM (no tests at all).
  • The dashboard against a real proxy: the browser tests in CI answer the API themselves; the suite that drives a real proxy is run by hand.

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:

  • The rules match wording. An attack phrased in a way no rule anticipates needs the optional classifier, and that has not been measured on a set its author did not write (see Benchmarks).
  • Images, audio and PDFs are not read.
  • Callers are authenticated only when you issue keys; requests without one are refused only with TAMGA_REQUIRE_KEY=true. See SECURITY.md.
  • Streamed responses are scanned only with output_rules.streaming on, and then only the model's text: tool-call arguments and thinking pass unread.
  • The first scan of a very large request is slow, about 250 KB of new text a second per core (agents).
  • Traffic from a browser to a chat site, and between an agent and its MCP servers, does not pass through a proxy at all. For three chat sites there is a browser extension, an MVP whose site selectors are unverified.
  • There is no multi-region replication, no SSO sign-in beyond GitHub OAuth (SSO settings are stored and used by no sign-in; the SCIM routes have no store behind them) and no custom roles.

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.

  • jugeni by Mike Czerwiński — an operator-state framework. Tamga reads its audit log to enforce locked decisions before a call is made.
  • MCPRadar — a pre-deployment security scanner for MCP servers. Static analysis before you deploy; Tamga at runtime.

Contributing

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.

License

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.


© 2026 Fatih Serdar Çakmak · tamgaproxy.com
ai-gateway
ai-security
anthropic-proxy
compliance
data-loss-prevention
gdpr
go
guardrails
kvkk
llm-firewall
llm-firewalls
llm-security
openai-proxy
pci-dss
pci-dss-compliance
pii-redaction
prompt-injection
reverse-proxy-server
self-hosted

Significant stargazers

12ya

45 followers · starred Jul 2026