Security Proxy

io.github.GuardBee由 GitHub 帳號 GuardBee 發布v1.37.1更新於 Nov 3, 2026

MCP gateway: many servers, one policy, lethal-trifecta blocking, PII masking, audit log

已驗證STDIO僅桌面開發者工具安全與監控

概覽

AI 產生的概覽

本機 MCP 安全代理,置於其他 MCP 伺服器之前,用來攔截提示注入、遮蔽個人資料、執行政策並寫入雜湊鏈稽核日誌。

可以做什麼
  • 把多個 MCP 伺服器放在同一個代理後面,工具以 upstream__tool 形式呈現
  • 對工具參數、結果、資源與提示中的提示注入進行攔截或警告,可選用離線分類器與 LLM 評審
  • 在回應中遮蔽個人資料、密鑰、私鑰與連線字串,或清空指定的 JSON 欄位
  • 依 YAML 政策規則執行允許、拒絕、遮蔽、警告、核准,隱藏工具,並跨伺服器阻擋致命三元組外送
  • 在作業系統沙箱中執行上游伺服器,磁碟唯讀、隱藏憑證並限制網路
  • 輸出稽核日誌、附 OWASP 與 MITRE 對應的簽章安全報告、AIBOM 以及 OpenTelemetry 追蹤
適用情境
當助理已依賴第三方 MCP 伺服器,而你希望在其前面加一層政策與稽核時適用,特別適合同時處理不可信內容與敏感資料的情境。也適合需要為安全審查或合規報告提供證據的團隊。
執行需求
以本機 stdio 程序執行,透過 npm 安裝(需要 Node.js)。它會啟動 -- 之後指定的目標 MCP 伺服器,或讀取 guardbee-proxy.yaml 設定檔。選用功能需額外準備:沙箱執行環境(docker、podman、bwrap、sandbox-exec)、用於逐使用者驗證的 OIDC 提供者與用戶端密鑰,以及分類器或評審所需的本機模型。
安裝前請注意
它會攔截並檢查助理與上游伺服器之間的所有流量,稽核日誌會記錄工具呼叫與判定原因。遙測預設關閉,但設定 GUARDBEE_TELEMETRY=1 會傳送伺服器、工具、耗時以及檔案路徑等短參數值。init 指令會改寫用戶端設定並把伺服器移入設定檔。選用分類器下載約 135 MB。注入規則屬啟發式,README 自述涵蓋範圍有限。

安裝

在 SourceWeft 中

  1. 開啟 儀表板中的 Security Proxy,將其新增到工作區。
  2. 為需要使用其工具的對話啟用該服務。

Desktop only,透過 STDIO。 STDIO 服務會啟動本機處理程序,因此需要 SourceWeft 桌面主機。

其他 MCP 客戶端

參照 儲存庫 中的啟動說明。

README

@guardbee/mcp-security-proxy

🇬🇧 English | 🇹🇷 Türkçe | 🇨🇳 中文 | 🇯🇵 日本語 | 🇰🇷 한국어 | 🇧🇷 Português | 🇪🇸 Español | 🇩🇪 Deutsch

[npm version] [npm downloads] [License: MIT]

A transparent security layer that sits in front of any MCP server. Blocks prompt injection attacks, masks PII in responses, and writes every request to an immutable audit log.

Telemetry is off by default. With GUARDBEE_TELEMETRY=1 it sends the server, tool, success, duration and short parameter values such as a file path; longer strings and fields like content, token or password are replaced with a placeholder. See @guardbee/mcp-telemetry.

Claude ──► MCP Security Proxy ──► Any MCP Server                │                ├─ Prompt injection detection  (27 rules, EN + TR)                ├─ PII + secret masking        (TC, VKN, IBAN, card, phone, 22 key formats)                ├─ Block or warn mode                ├─ Toxic-flow (lethal trifecta) blocking                └─ Hash-chained audit log

Features

  • Prompt Injection Protection — 27 rules in English and Turkish; precise ones block, generic phrases only warn
  • Tool-result injection — the same patterns applied to tool results, tagged MCP06:2025. Block mode replaces the result; warn mode prepends a warning
  • Session tool pin — the first tools/list is pinned. A later description or schema change is a rug pull (MCP03:2025). Block mode keeps serving the pinned definition and refuses the drifted call
  • PII Masking — TC Kimlik No, VKN, IBAN, card and phone numbers (checksum-validated), passport, SGK, MERSİS, date of birth, postal and KEP addresses, email, opt-in IP/MAC/plates, 22 provider key formats, private keys and connection strings are masked in responses
  • Block / Warn Mode — each interceptor can independently run in blocking or warning mode
  • Audit Log — configurable log written to console or a file
  • Gateway mode — several MCP servers behind one proxy, one YAML policy (allow / deny / mask / warn)
  • Toxic-flow blocking — tracks the lethal trifecta across servers and blocks the egress call that would complete it
  • LLM API gateway — OpenAI- and Anthropic-compatible endpoints: gateway keys instead of provider keys, PII masking and injection checks on prompts, token and spend budgets, usage and cost in the audit log
  • A2A gateway — Agent2Agent calls between agents go through the gateway: callers authenticate, injection in messages and artifacts is blocked, personal data is masked, push-notification webhooks into private networks are refused, and every call is audited
  • Coding-agent hooks — guardbee-hook applies the same policy to Claude Code's and Cursor's own shell, file and web tools
  • Zero Code Changes — attaches in front of any existing MCP server

Quick Start

bash
npm install -g @guardbee/mcp-security-proxy

Add to claude_desktop_config.json:

json
{  "mcpServers": {    "secure-filesystem": {      "command": "npx",      "args": [        "-y", "@guardbee/mcp-security-proxy",        "--", "npx", "-y",        "@modelcontextprotocol/server-filesystem", "/tmp"      ]    }  }}

The proxy launches whatever comes after -- as the target MCP server.


Gateway Mode: Several Servers, One Policy

Put every MCP server behind one proxy with a guardbee-proxy.yaml. init does the move for you:

bash
npx -y @guardbee/mcp-security-proxy@^1 init --client claude-desktop   # or cursor, claude-code, --file ./mcp.json

It moves each stdio server into ~/.guardbee/guardbee-proxy.yaml (mode 0600), backs up the client config and points it at the proxy. HTTP servers stay as they are and are reported, because they would bypass the policy. --dry-run prints both files without writing. Or by hand:

json
{  "mcpServers": {    "guardbee": {      "command": "npx",      "args": ["-y", "@guardbee/mcp-security-proxy@^1", "--config", "/path/to/guardbee-proxy.yaml"]    }  }}
yaml
version: 1upstreams:  github:    command: npx    args: ["-y", "@modelcontextprotocol/server-github"]    env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_TOKEN}" }   # read from the environment  postgres:    command: npx    args: ["-y", "@guardbee/mcp-db-gateway"]
labels:                       # override the heuristic labels  github__get_issue: [untrusted]  github__create_pull_request: [egress]
tools:                        # what the agent sees (globs on <upstream>__<tool>)  hide: ["*__delete_*"]  descriptions:    github__create_issue: "Create an issue in acme/* repositories only."
rules:                        # first match wins  - id: no-deletes    match: { tool: "postgres__delete_*" }    action: deny              # allow | deny | mask | warn | approve  - match: { tool: "postgres__query", args: { table: "salaries" } }   # dotted path → value, strings are globs    action: mask    mask: { fields: [salary, iban] }   # blank these JSON keys  - match: { label: destructive }    action: approve           # ask the person first
taint:  mode: strict                # strict | approve | warn | off  basis: capability           # capability | data — data: block only egress calls that carry sensitive data
interceptors:  anomaly:                    # off unless set; limits per session, sliding window    enabled: true    action: warn              # warn | block    callBurst: { count: 60, windowSeconds: 60 }
audit:  sink: file  filePath: ./guardbee-audit.jsonl
  • Tools and prompts appear as <upstream>__<tool>.

  • Toxic flow (lethal trifecta): each tool is labeled untrusted, sensitive, egress or destructive (name/description heuristics, overridable under labels). Once a session has read untrusted content (an untrusted tool or any resource) and sensitive data (a sensitive tool or any PII in a result), an egress call is blocked in strict mode and only logged in warn mode. The check spans servers: an issue read from GitHub plus a customer record from a CRM blocks a webhook on a third server.

  • Rules match on tool (glob), upstream, label, session (clean | tainted) and args. mask forces PII masking for that tool even when masking is off, and mask.fields also blanks the named JSON keys in text and structuredContent. A rule's allow does not skip the toxic-flow check; relabel the tool instead.

  • Topics (topics, match.topic): a rule can name a subject instead of a tool — "nothing about mergers or salaries leaves through this agent". A topic is a list of terms; text is on topic when minMatches (default 1) different terms appear. Matching folds case and Turkish letters (BİRLEŞME = birlesme), and a term may take a suffix (maaş finds maaşları, satın alma finds satın almayı); quote a term to match the whole word only ('"AI"'). The rule is checked on the call's arguments, and again on what comes back — the tool result, a resource, a prompt — so a subject that only shows up in the answer is caught too: deny withholds it, mask forces PII masking and mask.fields, warn logs, approve asks (once: not again for the answer of a call it already approved). In the LLM API gateway topic rules apply to the whole prompt, with tool: llm:<model>; approve refuses there, since an API call has no one to ask.

    yaml
    topics:  - id: ma    terms: [birleşme, "satın alma", "due diligence", acquisition, merger]  - id: salary    terms: [maaş, bordro, payroll, salary]    minMatches: 1rules:  - { id: no-ma-out,   match: { topic: ma, label: egress }, action: deny }  - { id: contractors, match: { topic: salary, user: "*@contractor.com" }, action: approve }  - { id: ma-to-llm,   match: { topic: ma, tool: "llm:gpt-*" }, action: deny }
  • Tool exposure (tools): expose is an allowlist of globs on the name the agent sees (absent: every tool), and hide removes tools after it. A hidden tool is left out of tools/list, so the model never reads its description (a smaller poisoning surface and fewer tokens), and a call to it is refused and counts toward anomaly.repeatedBlocks. descriptions replaces a tool's description with one you wrote; drift detection keeps watching the server's own. Unlike a deny rule, which refuses at call time, hiding takes the tool out of the agent's view.

  • Tool poisoning at tools/list (interceptors.toolPoisoning, on by default): every definition is scanned before the agent sees it — the description (yours, when you wrote one under tools.descriptions), parameter descriptions and enum values in the input schema, and the read-only annotation. It catches instructions aimed at the model (<IMPORTANT>, "do not tell the user", "ignore previous instructions"), cross-tool shadowing ("when using the X tool, always…"), requests to read .ssh/.env, hidden zero-width, bidi and ANSI characters, words with Cyrillic or Greek lookalikes, and a readOnlyHint that the description contradicts. In block mode (default; follows promptInjection.action when unset) a tool with a critical or high finding is left out of the list and its calls are refused; medium findings (encoded blobs) only warn. Each finding is logged once as ruleId: poisoning:<pattern>. The rules are the ones @guardbee/mcp-tool-poisoning-scanner uses, now shared through guard-core.

  • Credentials leaving (interceptors.egressSecrets, on by default): before an egress tool runs, the arguments it would actually receive are checked for credentials — provider keys (AWS, GitHub, OpenAI, Anthropic, Stripe, Slack, …), private keys and connection strings with a password; the same fixed-format rules PII masking uses, never the broad keyword ones. Data taint only knows what this session's tools returned; this also catches a key the model got from the prompt, a file or its own context. block (default; follows promptInjection.action when unset) refuses the call, warn logs it; the audit event has the credential masked either way. A PII token stays a token for an egress tool unless piiMasking.detokenizeForEgress, so only then is its value checked. allowTools exempts tools by glob (a deploy tool that takes a DB URL, say). Personal data is left to PII masking and taint.

  • Sampling (interceptors.sampling, off by default): a server can ask the agent's model for a completion (sampling/createMessage) — and a malicious one can use that to smuggle instructions to the model or pull data out. Off, the proxy does not offer sampling to servers at all. On, a request is answered only while one of that server's tool calls is running, by the session that made the call (two sessions calling the same server at once: refused, since it could belong to either); includeContext is forced to none, so a server never gets the conversation; maxTokens is capped (default 1024) and so is the number of requests per call (maxPerCall, default 3); the system prompt and messages get the injection scan; approval: true asks the person first. The model's answer goes back to the server, so it is treated as egress: a credential or data this session saw blocks it, and personal data is masked. Events use tool: sampling:<upstream> and ruleId: sampling:<check>. The capability is offered when the proxy starts, so turning it on or off needs a restart.

  • Approval: approve (a rule action, or taint.mode: approve for toxic flows) shows the person a yes/no form through MCP elicitation, with the tool, the reason and the arguments. Anything but an explicit yes — decline, cancel, no answer within approval.timeoutSeconds (default 120) — blocks the call. A client without elicitation support cannot approve, so the call is blocked with a message saying so.

  • Approval in the dashboard: with approval.channels: [elicitation, dashboard] and audit.dashboard set, a client that cannot show a prompt no longer blocks the call outright. The request goes to the GuardBee dashboard (MCP Gateway page) with the arguments PII-masked, workspace owners and admins get a notification, and the proxy holds the call until someone approves or denies it there or timeoutSeconds passes. Channels are tried in order; the first one that can ask gives the answer.

  • Data-based taint (taint.basis: data): the default capability basis blocks every egress call once a session holds untrusted and sensitive content. With data, the proxy keeps hashed fingerprints of what sensitive tools (and PII-bearing results) returned — personal data and credentials, id-like tokens, and runs of words — and treats an egress call as a toxic flow only when its arguments carry that data (12+ words in common, or an exact identifier; PII tokens count as their values) and the session also read untrusted content. The block message names the tool the data came from. Licence text, markdown boilerplate and text that also came back from ordinary results or from 3+ tools are ignored. Fingerprints are hashes, kept in memory per session. Trade-off: a paraphrase or a summary of the data is not caught; keep capability where that matters. In capability mode the same evidence is added to the block reason when found.

  • Anomaly checks (interceptors.anomaly): behaviour no single call shows, counted per session over a sliding window. callBurst — more than 60 tool calls in 60s (a loop, or a denial-of-wallet). sensitiveSweep — more than 25 different reads of sensitive tools in 300s (enumerating a store record by record; re-reading the same record does not count). destructiveBurst — more than 5 destructive calls in 60s. repeatedBlocks — more than 5 calls blocked or declined in 300s (probing the policy; a client that cannot show an approval prompt does not count). Each takes { count, windowSeconds }; the numbers above are the defaults. warn logs the first trip of each check; block refuses calls over the limit until the window slides, and after repeated blocks refuses every later call in the session. Events are warn/blocked with ruleId: anomaly:<check>, so the dashboard shows them without changes. Off unless configured.

  • Quotas (quotas): caps on tool calls over a fixed window, counted across sessions — where interceptors.anomaly watches one session, a quota holds when a user opens ten. Each entry has match (tool and user globs, upstream, label, group; all optional), limit, windowSeconds and per: user (default; the OIDC user, or the session without one), session, or gateway (one budget for everyone, e.g. a paid upstream). Example: quotas: [{ id: exports, match: { label: egress, group: contractors }, limit: 20, windowSeconds: 3600 }]. Every matching quota is checked and a call counts against all of them or none; only calls that are about to reach the upstream count, so a call the policy refused does not spend quota. A full quota refuses the call with the time to retry, logs ruleId: quota:<id>, and does not count toward anomaly.repeatedBlocks. Counters live in the proxy process: several replicas each keep their own.

  • Policy from the dashboard: policy: { source: dashboard, refreshSeconds: 60 } (with audit.dashboard) takes labels, rules, taint, approval, defaults and interceptors from the workspace policy edited in the GuardBee dashboard, instead of this file. Upstreams, listen and audit stay local. The proxy fetches it at start and re-checks it with an ETag; an update applies to the next call in every session (labels from the next tools/list). With no dashboard policy saved, the dashboard unreachable, or an invalid document, the proxy keeps the local or last good policy — it never runs without one.

  • Documents (interceptors.documents, off by default): images and blobs in tool results, embedded resources and resource reads — PDFs, screenshots, scanned pages — are read with @guardbee/mcp-document-scanner: the PDF text layer, and OCR for images and pages without text when the optional tesseract.js (and @napi-rs/canvas for scanned PDFs) are installed. A document holding personal data or a credential is replaced by a notice in block mode (ruleId: documents:pii) or logged in warn mode; its counts go into piiHits and taint the session like PII in text. A document that could not be read (OCR not installed, over maxBytes) is logged as documents:unread. { enabled: true, action: block, ocr: true, maxBytes: 26214400 }.

  • Personal-data counts: tool responses and resource reads that contained PII carry piiHits, a count per category (tc_kimlik, vkn, iban, credit_card, email, phone_tr; credentials as secret). Counts only, never the values, and counted even with masking off. The dashboard builds its KVKK report from them.

  • Tokenize: with interceptors.piiMasking.mode: tokenize the model sees <pii:tc_kimlik:7f3a9b21> instead of the value and can still pass it to another tool: the proxy puts the real value back on the way to that server. Tokens live in memory for the session only, and are not turned back into values for an egress tool unless piiMasking.detokenizeForEgress: true.

  • Prompts (prompts/get) get the same injection scan and PII masking as tool results.

  • interceptors.definitionDrift.recheck: on-change skips the per-call tools/list and re-checks only after a server sends tools/list_changed, which the proxy also passes on to the agent.

  • Build provenance (provenance, warn by default): before a stdio upstream starts, the proxy resolves the package npx / pnpm dlx / bunx would run and checks its npm provenance: a Sigstore-signed SLSA attestation (signature, certificate chain and transparency-log entry against the Sigstore trust root, fetched through TUF) whose statement names that exact tarball (sha512 = the registry's integrity), signed by a workflow of the repository it names, which must match the package's repository field. The command then runs pinned to the version that was checked (npx -y pkg → npx -y [email protected]), so a release published in between is not what starts. warn starts unverified packages and logs ruleId: provenance:unsigned | invalid | error | unsupported; require refuses them (per upstream: upstreams.<name>.provenance: off | warn | require). Results are cached per version in ~/.guardbee/provenance.json (provenance.cacheFile); another tarball under a version verified before counts as tampering. provenance.offline: true uses only the cache and the trust root on disk (pin exact versions for that). Check without starting anything: guardbee-proxy provenance --config <file> (exit code 1 when an upstream would be refused). PyPI (uvx, uv tool run): every file of the release must carry a PEP 740 attestation from its Trusted Publisher (signed by that repository's workflow, naming the file and its sha256), and uvx pkg runs as uvx pkg==<version>. Container images (docker run, podman run): the tag is resolved to a digest and the image's cosign keyless signature (sha256-<digest>.sig) is verified; the signer must be a workflow of the image owner's GitHub account for ghcr.io/<owner>/…, or match provenance.signers ("docker.io/acme/*": "https://github.com/acme/") elsewhere, and the command runs pinned to image@sha256:…. --format sarif writes the findings as SARIF 2.1.0 for code scanning. Limits: many packages publish without provenance (they show as unsigned, as do images signed another way than cosign, such as Docker Hub's mcp/ images); a private npm registry is used through provenance.registry or npm_config_registry, a PyPI mirror through provenance.pypiIndex (it needs the integrity API), and private images are not supported (anonymous registry tokens only); the single-server -- form does not check.

    yaml
    provenance:  mode: warn                 # off | warn | requireupstreams:  github:    command: npx    args: ["-y", "@modelcontextprotocol/server-github"]    provenance: require      # this one must be verified to start
  • Persistent tool pins (interceptors.definitionDrift.persist, on by default in gateway mode): the definitions a server first listed (name, description, input/output schema, annotations) are pinned by SHA-256 in ~/.guardbee/pins.json (pins.file to move it; written atomically, mode 0600), so a server that rewrites a tool between two runs, after an update or a compromise, is not trusted again just because a new session started. On a change, onChange: approve (default) keeps showing the agent the approved definition and asks the person before the first call, with the old and new description side by side; once approved, the new definition is pinned. block refuses the call, warn logs and uses the new one. A new tool on a known server is held the same way. Audit events (ruleId: pin:changed | pin:added | pin:removed) carry both hashes; the descriptions only with audit.includePayloads. Manage them with guardbee-proxy pins list | approve <upstream> [tool] | reset [upstream] [tool], and seed them from CI with guardbee-proxy pins import <baseline.json> --upstream <name> (an mcp-rug-pull-detector baseline: same hashes). The single-server -- form keeps per-session pins only.

  • Audit log is hash-chained. By default it stores a SHA-256 of the arguments instead of the arguments, and no results; set audit.includePayloads: true to log them. Check a log with guardbee-proxy verify-audit ./guardbee-audit.jsonl.

  • guardbee-proxy validate --config guardbee-proxy.yaml checks a config without starting any server.

  • Without --config, the proxy also picks up ./guardbee-proxy.yaml or GUARDBEE_PROXY_CONFIG=<file>.yaml.

The single-server -- form above keeps its 0.x behavior: tool names are not prefixed, payloads are logged, and a toxic flow is only logged as a warning.


Sandboxed Upstreams

Policy, taint and masking see MCP messages. A stdio server is also a process with your privileges: a compromised package can read ~/.ssh or a .env file and send it out without ever answering a tool call. upstreams.<name>.sandbox runs it in an OS sandbox instead:

yaml
upstreams:  files:    command: npx    args: ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects/demo"]    sandbox:      runtime: sandbox-exec          # docker | podman | bwrap | sandbox-exec | none (default: no sandbox)      fs:        readWrite: ["/Users/me/projects/demo"]        readOnly: []                 # containers see only these two lists        deny: ["~/clients"]          # hidden on top of the built-in credential list      env: ["GITHUB_TOKEN"]          # host variables passed in; the upstream's own `env` always is      network: open                  # open (default) | none | allowlist      # allowHosts: ["api.github.com", "*.githubusercontent.com"]   # network: allowlist      limits: { cpus: 1, memory: 512m, pids: 128 }   # docker / podman only

What every runtime enforces:

  • Read-only disk. Writes go only to fs.readWrite and a private home, ~/.guardbee/sandbox/<name> (HOME and TMPDIR point there, so the npm or uv cache of a sandboxed server is its own and cannot poison yours).
  • Credentials hidden. ~/.ssh, ~/.aws, ~/.azure, ~/.config/gcloud, ~/.kube, ~/.docker, ~/.gnupg, ~/.netrc, ~/.npmrc, ~/.pypirc, ~/.git-credentials, ~/.config/gh, password stores, browser profiles and keychains, .env files, the proxy's own pin and provenance files, and fs.deny.
  • No host environment. Only PATH, LANG, LC_ALL, TERM, TZ, the names in env and the upstream's env.
  • network: none cuts the network (the package must then already be installed: an image that has it, or a run with open first).
  • network: allowlist with allowHosts (names, IP literals or globs such as *.example.com) leaves one way out: an HTTP proxy in the gateway (CONNECT for TLS, plain HTTP forwarded) that connects only to those hosts. The address is checked where the connection is made, so a name that resolves to a cloud metadata, link-local or (unless blockPrivate: false) private or loopback address is refused, DNS rebinding included. The sandbox gets HTTP_PROXY / HTTPS_PROXY (and NODE_USE_ENV_PROXY, npm_config_*proxy), which the upstream's env cannot override; a client that ignores them gets no network rather than a way around. Every refusal is an audit event (ruleId: network:host | network:metadata | network:private, destination), so it reaches the SIEM and OTel exports like the HTTP upstream guard's. A package runner downloads too: list registry.npmjs.org for npx, pypi.org and files.pythonhosted.org for uvx.
RuntimeOSFile systemCredentials hiddenNetwork noneNetwork allowlistLimits
docker / podmanLinux, macOS, Windowsonly mounted paths; read-only root, --cap-drop ALL, no-new-privilegesinside mounts (tmpfs / /dev/null)--network nonean --internal network shared only with a forwarder sidecar (node:22-alpine) that relays to the proxy (a unix socket on Linux, host.docker.internal on Docker Desktop / podman machine); all three are removed on exitcpus, memory, pids
bwrap (bubblewrap)Linuxwhole disk read-only, --unshare-alltmpfs / /dev/null over themno network namespace shareown network namespace (loopback only) with a forwarder from 127.0.0.1:3128 to the proxy's unix socket—
sandbox-exec (Seatbelt)macOSwhole disk readable, writes denieddeny file-read* (any depth for .env)deny network*deny network* except the proxy's port on localhost—

Containers use image, or node:22-alpine for node/npx/npm/pnpm, ghcr.io/astral-sh/uv:python3.12-alpine for uvx/uv/python, oven/bun:1-alpine for bun/bunx. Environment values go to the container through the client's environment (-e NAME), never on the command line. On Linux the container runs as your user, so files it writes stay yours.

  • No silent fallback. A sandbox that cannot work on this host (runtime not on PATH, sandbox-exec on Linux, limits without a container, a readWrite of ~ or /, a path inside a credential store) stops the gateway with the reason, and guardbee-proxy validate lists the same problems. Start failures are audit events ruleId: sandbox:invalid | sandbox:failed.
  • guardbee-proxy init --sandbox [runtime] puts every imported stdio server in a sandbox (default: sandbox-exec on macOS, bwrap on Linux when installed, otherwise docker), with the directories it was given as arguments writable.
  • guardbee-proxy init --grades prints each imported server's GuardBee MCP Index grade (A–F, from its published package: static scan, known vulnerabilities and malware, build provenance, maintenance; the index is downloaded whole and searched locally, so no server name is sent). --min-grade C leaves out servers graded below C: they are neither moved behind the proxy nor left in the client config, where they would run unchecked; the backup keeps them. Servers with no grade (local commands, remote URLs, servers outside the official registry) are always moved.
  • Limits of the model. sandbox-exec is deprecated by Apple but still the macOS mechanism; it does not limit CPU or memory. bwrap needs unprivileged user namespaces (some distributions restrict them with AppArmor). Neither hides files you did not list from reading on the non-container runtimes: only the credential list, .env files and fs.deny are hidden, everything else stays readable (use a container for an allowlist). network: allowlist filters by host name and port-agnostic: it does not inspect what is sent to an allowed host. Raw TCP and UDP (a database driver, DNS) do not go through an HTTP proxy, so they get no network under it. Windows has no built-in runtime: use docker or podman.

Upstream Identity

By default an HTTP upstream gets the credential in its headers, the same for every caller. In a shared gateway that means everyone acts as one account downstream. upstreams.<name>.auth makes each caller act as themselves. The caller's own token is never forwarded, because the MCP spec forbids token passthrough.

Token exchange (RFC 8693). The caller signs in to the gateway with OIDC (listen.oidc). For each upstream, the gateway exchanges that token at the authorization server for one whose audience is the upstream.

yaml
upstreams:  files:    url: https://files.internal.example/mcp    auth:      mode: token-exchange      tokenEndpoint: https://sso.example/realms/acme/protocol/openid-connect/token      clientId: guardbee-gateway      clientSecretEnv: GATEWAY_CLIENT_SECRET   # never in the file      audience: files-api                      # RFC 8693 audience; resource: (RFC 8707) also works      scopes: [files:read]                     # what every call gets      toolScopes: { "delete_*": [files:write] } # step-up for these tools
  • Keycloak (26.2+, standard token exchange): turn on Standard token exchange on the gateway client. The caller's token must name the gateway in aud (an audience mapper on the MCP client), and the gateway client needs an audience mapper for the upstream's client. The CI test runs against Keycloak in docker.
  • Microsoft Entra ID (on-behalf-of): point tokenEndpoint at https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token. Entra uses the OBO grant (urn:ietf:params:oauth:grant-type:jwt-bearer), not RFC 8693, so put an RFC 8693 broker in front of it, or use a provider that speaks 8693.
  • Okta (custom authorization server, token exchange enabled on the app): tokenEndpoint: https://<org>.okta.com/oauth2/<server>/v1/token, audience the target authorization server's audience.

Per-user OAuth. For third-party services (GitHub, Google, Atlassian…), each user connects their own account once.

yaml
listen: { transport: http, oidc: { … } }upstreamAuth:  publicUrl: https://gateway.example          # redirect URI: https://gateway.example/oauth/callback  encryptionKeyEnv: GUARDBEE_TOKEN_KEY         # openssl rand -base64 32upstreams:  github:    url: https://api.githubcopilot.com/mcp/    auth:      mode: oauth-per-user      authorizationEndpoint: https://github.com/login/oauth/authorize      tokenEndpoint: https://github.com/login/oauth/access_token      clientId: Iv1.abc123      clientSecretEnv: GITHUB_APP_SECRET      issuer: https://github.com                # RFC 9207 iss check, when the server sends it      scopes: [repo]

Until a user connects, the upstream offers one tool, <name>__connect_account. It returns a link to the gateway's consent page, which shows the upstream, the user and the scopes, with Allow and Cancel. Allow goes to the authorization server, then back to the callback, and the real tools appear.

The flow is protected at every step:

  • the consent link is signed, bound to the user, valid ten minutes and usable once;
  • the form carries a CSRF token, also set as an HttpOnly; SameSite=Strict cookie;
  • the authorization request uses PKCE S256 and a single-use state;
  • there is one exact redirect URI;
  • the iss in the response is checked;
  • the pages cannot be framed (frame-ancestors 'none', X-Frame-Options: DENY) or cached.

Tokens are stored per user and upstream, encrypted with AES-256-GCM (upstreamAuth.tokenFile, default ~/.guardbee/upstream-tokens.enc), and refreshed when they expire.

Scope minimization and step-up. A call asks for scopes, plus the toolScopes of the tool being called. When an upstream answers 401/403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="…", the gateway gets a token with those scopes and retries once. Token exchange does this silently; per-user OAuth sends the user back to the consent page for the larger set. Each elevation is an audit event (type: approval, ruleId: auth:step-up, with the before and after scopes), and so is each consent (auth:consent).

Audit. Each tool response from such an upstream records user, the caller at the gateway, and upstreamIdentity, whom the upstream saw (the downstream token's preferred_username, upn, email or sub). Tokens are never logged.

Prompt-Injection Classifier (optional)

The rules catch phrases they know: 11–19% of the attacks on GuardBee's Turkish + English benchmark. The optional classifier @guardbee/injection-model is a small multilingual model that runs offline on CPU. It covers paraphrases, translations, Turkish suffix forms and jailbreak personas, at about 8 ms per text. It is off unless configured.

bash
npm i -g @guardbee/injection-model && guardbee-injection-model download   # ~135 MB, hash-checked; the only network access
yaml
classifier:  # modelDir: /opt/guardbee/injection-model   # default: ~/.cache/guardbee/injection-model  # url: http://127.0.0.1:8766                # or the sidecar (guardbee-injection-model serve); tokenEnv for its bearer token  warnAt: 0.5          # at or above: warn  blockAt: 0.9         # at or above: the interceptor's action (block, or warn in warn mode)  timeoutMs: 1000  onError: open        # open: keep the rules' decision; closed: block when the classifier fails  apply: [arguments, results, resources, llm]   # tool arguments, tool results, resources, LLM gateway prompts
  • The rules stay authoritative. The classifier only sees what the rules did not block. It can add a warning or a block, and never clears a rule's finding. What is still a warning afterwards goes to the judge when one is configured, so the classifier is the fast first pass and the judge the second opinion.
  • On record. Every audit event the classifier touched carries classifier: { model, score, latencyMs }, or error when it failed. Its blocks have ruleId: classifier:injection (llm:injection-classifier in the LLM gateway). The CLI scanner takes --classifier and reports classifierModel and classifierScore in SARIF.
  • Numbers. On the benchmark's held-out split, rules + classifier score F1 93.7 in Turkish and 91.6 in English, with 2.1% false positives overall, at 8 ms mean / 30 ms p95 per text on a laptop CPU. The rules alone score 28.6 and 20.4; the model card has the full table, the training data and the limits.
  • Rolling out. Start with blockAt: 1 (warnings only) and read the warnings on your own traffic before blocking.

LLM Judge (optional)

The injection rules are fast and explainable but match phrases they know. An optional judge gives a second opinion from a model: a paraphrased, indirect or translated instruction, or an article that only quotes one. It is off unless configured, and runs best on a local model (Ollama, llama.cpp, vLLM, LM Studio: any OpenAI-compatible endpoint), so nothing leaves the machine.

yaml
judge:  endpoint: http://127.0.0.1:11434/v1   # Ollama; a non-local endpoint also needs allowRemote: true (and https)  model: qwen2.5:7b  budgetMs: 500        # time the judge may add to a call; past it, onError decides  onError: open        # open: keep the rules' decision; closed: block the borderline item  minConfidence: 0.7  mayClear: false      # true: a confident "benign" removes a rules warning (still audited)  scope: borderline    # borderline: only what the rules warned about; results: every tool result too
  • Raise, not clear. The judge sees only what the rules left open: a warn on tool arguments or results (with scope: results, also every result the rules let through). A confident malicious verdict raises it to the configured action (interceptors.*.action, usually block, audit ruleId: judge:injection). Anything else keeps the rules' decision. A benign verdict clears a warning only with mayClear: true, and the cleared warning stays in the audit log.
  • Budget. A judge that does not answer within budgetMs (or fails) leaves the warning (onError: open) or blocks the borderline item (closed). A result the rules found clean is never blocked because the judge is down.
  • On record. Every audit event the judge touched carries judge: { model, version, verdict, confidence, latencyMs, cached, outcome }. CEF exports name the model in flexString1. Verdicts are cached by input (cacheSize).
  • Numbers. On GuardBee's judge benchmark (50 English and Turkish injection samples), the rules catch 16% of the attacks. qwen2.5:7b catches all of them with no false positives, at about 1 s per call on a laptop; qwen2.5:3b catches 96% at 0.7 s. The set is small and the numbers optimistic; see the caveats there.

Security Reports

guardbee-proxy report turns the audit log into a periodic report for a security team or an auditor: totals, the findings grouped and mapped to OWASP Top 10 for LLM Applications (2025) and MITRE ATLAS, personal-data counts, the rules and tools that blocked most, and the state of the hash chain. Payloads are never in it — only counts and the logged reasons.

bash
openssl genpkey -algorithm ed25519 -out guardbee-report.key          # once; keep it privateopenssl pkey -in guardbee-report.key -pubout -out guardbee-report.pubguardbee-proxy report ./guardbee-audit.jsonl --days 30 --lang tr --key guardbee-report.key --out ekim.htmlguardbee-proxy report ./guardbee-audit.jsonl --from 2026-10-01 --to 2026-11-01 --format json --out october.jsonguardbee-proxy verify-report ekim.html --pubkey guardbee-report.pub
CategoryOWASP LLM Top 10MITRE ATLAS
Prompt injection in arguments and promptsLLM01AML.T0051.000, AML.T0054
Instructions hidden in tool results, resources and promptsLLM01, LLM05AML.T0051.001
Attempts to extract the system promptLLM07, LLM01AML.T0056
Poisoned tool definitionsLLM01, LLM03AML.T0051.001, AML.T0053
Tool definitions changed after approval (rug pull)LLM03AML.T0010, AML.T0053
Sensitive data or credentials on their way out (toxic flows, credential egress, documents)LLM02, LLM06AML.T0057
Calls refused or held by policyLLM06AML.T0053
Bursts, quotas and budgetsLLM10AML.T0029, AML.T0034
  • Formats: Markdown (default), self-contained HTML (light and dark), JSON; --lang en|tr.
  • Integrity: the report carries the SHA-256 of its canonical JSON and the hash of the last audit event in the period, so it is tied to the hash chain. With --key (Ed25519, PEM) it is signed and carries the public key. verify-report checks a .json, .html or .md report; pass --pubkey to pin the signer — the embedded key alone only proves the report was not changed after signing, not who signed it.
  • A broken hash chain is reported in the report and on stderr.
  • The mapping lives in @guardbee/guard-core (classifyEvent, THREAT_CATEGORIES) and is shown by @guardbee/mcp-threat-rules' framework_map tool.
  • Compliance frameworks (--framework eu-ai-act | iso42001 | soc2 | nist-ai-rmf): adds a section that lists the framework's requirements the gateway supports, each with its citation (EU AI Act article, ISO/IEC 42001 Annex A control, SOC 2 Trust Services Criterion, NIST AI RMF subcategory), and the period's evidence for it: the findings in the categories mapped to it, and the gateway capabilities seen at work (the hash-chained log for record-keeping, approvals for human oversight, pins and provenance checks for supplier controls…). The section is part of the signed report. The tables are GuardBee's reading of the texts, versioned (mappingVersion) in @guardbee/guard-core (COMPLIANCE_FRAMEWORKS): evidence for an assessment, not a certification.

AI bill of materials

guardbee-proxy aibom --config guardbee-proxy.yaml --out aibom.cdx.json writes a CycloneDX 1.6 AIBOM of what the gateway puts in front of an agent: each MCP server as a component with its package URL (pkg:npm, pkg:pypi, pkg:oci), version when pinned, build-provenance status and source repository from the provenance cache, and sandbox settings; each server as a service with its tools nested (name, description, the SHA-256 the definition is pinned to, declared hints), remote servers with their endpoint; and the LLM gateway's routes as machine-learning-model components with their provider. It reads the config, the pin file and the provenance cache and makes no request; environment values, headers and keys are never in it. The output validates against the official CycloneDX 1.6 schema (tested).

Observability (OpenTelemetry)

Every tool call is a span named tools/call <tool> (kind SERVER) with the MCP semantic-convention attributes — mcp.method.name, gen_ai.tool.name, gen_ai.operation.name: execute_tool, mcp.session.id, enduser.id (OIDC) — plus guardbee.upstream, guardbee.labels and guardbee.pii_hits. The call to the upstream is a child span (kind CLIENT, server.address = upstream). Every audit event of the call — blocks, toxic flows, approvals, warnings, a server's sampling requests — is a span event (guardbee.blocked, … with guardbee.rule_id and guardbee.reason), and a blocked call carries guardbee.blocked: true. Arguments and results are never put on spans.

The proxy depends only on @opentelemetry/api, so without an SDK the spans cost nothing. To export over OTLP/HTTP, install the SDK next to the proxy and set the standard environment:

bash
npm i -g @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-http @opentelemetry/resourcesexport OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4318export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer …"   # if your collector needs itexport OTEL_SERVICE_NAME=guardbee-gateway                      # default: guardbee-security-proxy

The Docker image already has the SDK. OTEL_SDK_DISABLED=true or OTEL_TRACES_EXPORTER=none keeps it off, and a process that registered its own SDK first (zero-code instrumentation through NODE_OPTIONS) keeps that one.

Remote Mode (HTTP)

The proxy can run as a shared network service instead of a subprocess of one client. Agents connect over Streamable HTTP, and upstreams can be remote Streamable HTTP servers too.

yaml
version: 1listen:  transport: http              # stdio (default) | http  host: 0.0.0.0                # 0.0.0.0 in a container; 127.0.0.1 on a laptop  port: 8787  path: /mcp  apiKeys: ["${GUARDBEE_PROXY_KEY}"]   # agents send Authorization: Bearer <key>  oidc:                       # and/or: users sign in with your identity provider    issuer: https://login.acme.com    audience: guardbee-gateway  maxSessions: 100upstreams:  github:    command: npx    args: ["-y", "@modelcontextprotocol/server-github"]  linear:    url: https://mcp.linear.app/mcp     # a Streamable HTTP upstream    headers: { Authorization: "Bearer ${LINEAR_TOKEN}" }    network: { mode: allowlist, allowHosts: ["mcp.linear.app"] }   # only these hosts, even through a redirectaudit:  sink: file  filePath: /var/log/guardbee/audit.jsonl  dashboard:    url: https://app.guardbee.ai/api/v1/gateway/events    apiKeyEnv: GUARDBEE_API_KEY        # workspace API key with the gateway.write scope
  • Sessions are isolated. Each MCP session gets its own taint state, PII tokens and tool pins, so one agent's toxic flow never blocks another. All sessions share the upstream connections. Audit events carry the sessionId. MCP 2026-07-28 has no sessions; see MCP Protocol Versions for what takes their place.

  • No key, no network. Listening on anything other than loopback without listen.apiKeys or listen.oidc is a config error. Keys are compared in constant time, and a wrong or missing key gets 401.

  • Network egress (upstreams.<name>.network): every connection the proxy makes to an HTTP upstream, including each redirect hop, is checked at connect time: the name is resolved, every address is checked, and the socket connects to the checked address, so a DNS answer that changes between check and connect (rebinding) cannot reach an internal host. Link-local addresses (cloud metadata such as 169.254.169.254), 0.0.0.0 and reserved ranges are always refused, in any encoding (octal/hex IPv4, IPv4-mapped or NAT64 IPv6). blockPrivate refuses loopback and private addresses (RFC 1918, CGNAT, fc00::/7); unset, it follows the configured URL: a server on localhost or a private address may stay there, a public one may not reach your network. mode: allowlist with allowHosts (*.example.com globs) limits the hosts. Refused connections are audit events type: blocked, ruleId: network:private | network:metadata | network:reserved | network:host, with destination (host and address, never the request), so they reach the SIEM exporters and the dashboard. Stdio servers open their own sockets; limiting those needs an OS sandbox and is not covered here.

  • Directory groups (listen.ldap): LDAP / Active Directory groups are added to the groups a caller already has — an OIDC user, or an LLM gateway key's user — so group matches in rules, quotas, llm.access and budgets can use the groups your organisation already maintains. The directory never makes a caller known: authentication stays with API keys, OIDC or gateway keys.

    yaml
    listen:  ldap:    url: ldaps://dc.acme.com                # certificates verified; caFile for a private CA    bindDn: "CN=svc-guardbee,OU=Service,DC=acme,DC=com"    bindPasswordEnv: LDAP_BIND_PASSWORD    baseDn: "DC=acme,DC=com"    userFilter: "(|(mail={user})(userPrincipalName={user}))"   # {user} is escaped (RFC 4515)    groupAttribute: memberOf                 # AD; or groupSearch: { baseDn, filter: "(member={dn})", nameAttribute: cn }    groupName: cn                            # CN=Platform Eng,OU=… → "Platform Eng"; dn keeps the DN    cacheSeconds: 300    onError: deny                            # directory down: 503 (default) | token-groups: go on without it

    SAML is not a proxy setting: MCP and the LLM endpoints authenticate with bearer tokens, and identity providers that speak SAML (Entra ID, Okta, ADFS, Keycloak) also issue OIDC tokens — use listen.oidc with them, and listen.ldap for groups the token does not carry.

  • Users (listen.oidc): the proxy accepts JWTs from your OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, Google…) next to or instead of API keys. It checks the signature against the issuer's JWKS (found through its discovery document, or jwksUri), the issuer, the audience (required: a token minted for another service is refused) and the lifetime. The user comes from userClaim (default sub; email is common) and the groups from groupsClaim (default groups, a list or a space-separated string). Rules can match user (glob, e.g. "*@contractor.com") and group, and every audit event carries user. A session belongs to the user who opened it: another user's token gets 403 on it. A 401 carries WWW-Authenticate: Bearer resource_metadata=…, and /.well-known/oauth-protected-resource (RFC 9728) names the issuer, so MCP clients that implement the authorization spec find where to sign in; behind a reverse proxy set oidc.resource to the public URL.

  • SIEM export (audit.exporters): every audit event also goes, batched and off the request path, to Splunk, Elasticsearch/OpenSearch or a syslog collector. Credentials come from environment variables; events are the same hashed, payload-free (unless includePayloads) events as the local log.

    yaml
    audit:  exporters:    - { type: splunk,  url: "https://splunk:8088/services/collector/event", tokenEnv: SPLUNK_HEC_TOKEN, index: security }    - { type: elastic, url: "https://es:9200", index: guardbee-audit, apiKeyEnv: ES_API_KEY }   # or usernameEnv / passwordEnv    - { type: syslog,  host: siem.acme.internal, port: 6514, protocol: tls, format: cef, caFile: /etc/ssl/acme-ca.pem }

    Splunk gets HEC events (sourcetype: guardbee:audit). Elasticsearch gets _bulk create operations with the event hash as _id, so a retried batch never duplicates (a 409 counts as sent), and @timestamp. Syslog is RFC 5424 over UDP, TCP or TLS (certificates always verified; caFile for a private CA), with octet-counting (default) or newline framing, facility 13 (log audit) by default and the message as CEF (default) or JSON. CEF puts act, suser, rt, msg and tool / upstream / ruleId / sessionId / piiHits / labels in cs1–cs6; severity 8 for blocked and toxic_flow, 5 for warn and approval, 3 otherwise. Each destination keeps its own queue (up to 10,000 events): a SIEM that is down gets the events later; one that refuses them (401, a mapping error) is reported once on stderr.

  • Several replicas (state): quotas and LLM budgets are counted in the process by default, so each replica behind a load balancer would allow the full limit. With state.backend: redis every replica counts against the same numbers: the check and the count run as one Lua script, so replicas never both take the last call, and keys expire with their window (Redis 6 or later; the keys share one hash tag, so Redis Cluster works too). The proxy speaks RESP itself: nothing to install. If Redis cannot be reached, onError decides: local (default) counts in the process until it is back, fail-open lets calls through uncounted, fail-closed refuses calls that have a quota or budget; the outage and the recovery are audit events (ruleId: state:unavailable | state:recovered). Anomaly windows stay per session, which is per replica anyway: an MCP session lives on one replica.

    yaml
    state:  backend: redis                                  # memory (default) | redis  url: rediss://:${REDIS_PASSWORD}@redis.internal:6380/0   # rediss:// = TLS, certificates verified; /0 = database  keyPrefix: guardbee                             # keys: {guardbee}:quota:… and {guardbee}:budget:…  onError: local                                  # local | fail-open | fail-closed  timeoutMs: 250
  • GET /healthz returns {"ok": true, "sessions": N, "contexts": M} (2025-era sessions and 2026-07-28 contexts). Sessions and contexts above maxSessions get 503.

  • Dashboard: audit.dashboard sends every audit event, batched, to the GuardBee dashboard with a workspace API key taken from apiKeyEnv. The key never sits in the file. If the dashboard is unreachable, events wait in memory (up to 10,000) and go out later. A rejected key is reported once on stderr. Tool calls never wait for the dashboard.

  • init now also moves Streamable HTTP servers (url + headers) behind the proxy. Legacy SSE servers stay in the client config and are reported.

Docker:

bash
docker build -t guardbee/mcp-security-proxy packages/security-proxydocker run -p 8787:8787 -e GUARDBEE_PROXY_KEY=... -e GUARDBEE_API_KEY=... \  -v "$PWD/guardbee-proxy.yaml:/etc/guardbee/guardbee-proxy.yaml:ro" guardbee/mcp-security-proxy

MCP Protocol Versions

The proxy speaks MCP 2026-07-28 and 2025-11-25 (and the 2025 revisions before it) on both sides, and mixes them: a 2025-era agent can use a 2026-07-28 server through the proxy, and the other way round. It is built on the MCP TypeScript SDK v2.

  • Agents. Over stdio the revision is settled when the client connects. Over HTTP each request says which revision it speaks: 2025-era clients get a session as before (Mcp-Session-Id), 2026-07-28 requests are served without one.

  • Upstreams. By default the proxy asks each server for 2026-07-28 (server/discover) and falls back to the 2025 initialize handshake. Pin a revision per upstream with protocol:

    yaml
    upstreams:  linear:    url: https://mcp.linear.app/mcp    protocol: auto          # default | 2025-11-25 (no probe) | 2026-07-28 (modern only)

    On stdio, auto starts the server once more for a moment to probe it; set protocol: 2025-11-25 for a server that must not be started twice. A server in a docker or podman sandbox defaults to 2025-11-25; with auto or 2026-07-28 it is probed in its own container.

  • What replaces the session (HTTP, 2026-07-28). Taint, PII tokens, tool pins and anomaly counters are kept per user and context: the user from the OIDC token (callers with an API key count as one user), and the context from an optional X-GuardBee-Context header (1–128 letters, digits, ., _, :, -), for example one value per agent conversation. Without the header a user's calls share one context, which errs on the side of blocking: data one conversation read still counts when another tries to send something out. A context is dropped after listen.contextIdleSeconds (default 1800) without a request; sessions and contexts together are capped by listen.maxSessions. Discovery and subscriptions/listen requests (recognised from the Mcp-Method header) take no context.

  • Approvals. A 2026-07-28 server cannot send the client an elicitation request in the middle of a call. When a rule needs approval before a call, the proxy answers the call with input_required; the client shows the form and sends the call again with the answer, and only then does the call reach the server. The answer travels in a signed requestState bound to the user, the tool and the exact arguments, so it cannot be reused for another call or forged. Approvals that can only be asked after the server answered (topic rules on results) and sampling approvals go to the dashboard channel when approval.channels has one, and are refused otherwise. Behind a load balancer, give every replica the same key so any of them can check the answer:

    yaml
    listen:  stateKeyEnv: GUARDBEE_STATE_KEY   # at least 32 bytes; default: a random key per process
  • Sampling. A 2026-07-28 client cannot be asked for a completion in the middle of another server's call, so with such a client servers' sampling requests are refused (sampling:unavailable). A 2026-07-28 server that asks for sampling during a call is still handled as before when the agent is on 2025-11-25.

  • No SSE. The proxy connects to and serves Streamable HTTP only. guardbee-proxy init leaves SSE servers out and points at the /mcp endpoint most of them now serve instead of /sse.

LLM API Gateway

In HTTP mode the proxy can also stand between your apps and the model providers. Apps point their SDK's base URL at the proxy and use a gateway key; the real provider keys stay in the proxy's environment and never reach code, laptops or CI.

yaml
version: 1listen: { transport: http, host: 0.0.0.0, port: 8787 }upstreams: {}                    # MCP servers are optional when llm is setllm:  path: /v1                      # POST /chat/completions, /responses, /embeddings, /messages; GET /models  providers:    openai:    { api: openai,    baseUrl: https://api.openai.com/v1, apiKeyEnv: OPENAI_API_KEY }    anthropic: { api: anthropic, baseUrl: https://api.anthropic.com, apiKeyEnv: ANTHROPIC_API_KEY }    local:     { api: openai,    baseUrl: http://ollama:11434/v1,    apiKeyEnv: OLLAMA_KEY }  routes:                        # first match wins; a request only goes to a provider of its own API    - { model: "gpt-*",    provider: openai }    - { model: "claude-*", provider: anthropic }    - { model: "llama*",   provider: local }    - { model: "gpt-4o-internal", provider: openai, masking: off }   # explicit passthrough, audited  keys:                          # gateway keys, one per person or app (values from the environment)    - { keyEnv: GB_KEY_ALICE,   user: [email protected], groups: [eng] }    - { keyEnv: GB_KEY_BILLING, user: billing-svc,    groups: [apps] }  pricing:                       # USD per 1M tokens, for cost in the audit log and costUsd budgets    "gpt-4o":   { input: 2.5, output: 10 }    "claude-*": { input: 3,   output: 15 }  budgets:    - { id: per-user-daily, per: user,    windowSeconds: 86400, costUsd: 20 }    - { id: contractors,    per: user,    windowSeconds: 3600,  requests: 100, match: { group: contractors } }    - { id: team-tokens,    per: gateway, windowSeconds: 86400, tokens: 5000000, match: { model: "gpt-*" } }  access:                        # which models a caller may ask for; matching entries add up    - { group: contractors, allow: ["gpt-4o-mini"] }    - { user: "*@acme.com", allow: ["gpt-*", "claude-*", "llama*"] }  accessDefault: allow           # for callers no entry matches: allow | deny  sensitive:                     # prompts with personal data go to your own model instead    model: llama3.1              # needs a route for the request's API, or the request is refused    on: { pii: true, categories: [tc_kimlik, iban, passport], topics: [ma] }    mask: false                  # the data stays in-house, so it is not masked (true: keep masking)  maskResponses: false           # also mask PII the model writes (prompts are always checked)  timeoutSeconds: 120  maxTextChars: 2000000          # longest single text the proxy masks; longer → 413  documents:                     # read inline PDFs, images and audio instead of refusing PDFs/audio (415) and passing images; off by default    pdf: true    images: true                 # OCR inline images (needs tesseract.js)    ocr: false                   # true: OCR PDF pages with no text layer (needs tesseract.js + @napi-rs/canvas)    languages: [tur, eng]        # Tesseract languages    # langPath: /opt/tessdata    # local <lang>.traineddata, so OCR never downloads language data    # audio:                     # transcribe input_audio with your own OpenAI-compatible speech-to-text service    #   url: http://127.0.0.1:8080/v1/audio/transcriptions    #   model: whisper-1         # sent as the form's model field    #   language: tr             # optional hint; unset: detected    #   apiKeyEnv: STT_API_KEY   # optional, sent as Authorization: Bearer    maxBytes: 20971520           # per file; larger → 415    maxPages: 50                 # a longer PDF → 415    timeoutSeconds: 30           # reading time per file; over it → 415interceptors:  piiMasking: { enabled: true, mode: tokenize }
python
from openai import OpenAIclient = OpenAI(base_url="https://guardbee.acme.internal/v1", api_key=os.environ["GB_KEY"])
ts
import Anthropic from "@anthropic-ai/sdk";const client = new Anthropic({ baseURL: "https://guardbee.acme.internal", apiKey: process.env.GB_KEY });

What every request goes through, in order:

  1. Key. Authorization: Bearer (OpenAI SDKs) or x-api-key (Anthropic SDKs). A gateway key from llm.keys gives the user and groups; otherwise listen.apiKeys and listen.oidc tokens are accepted too. When llm.keys is set and listen has no keys, an unknown key is always refused — even on loopback.
  2. Route and access. The model name picks the provider; a model no route serves gets 404. llm.access decides which models the caller may ask for: every entry matching the user (glob) or a group adds its model globs; with no entry matching, accessDefault applies. A refused model gets 403 (ruleId: llm:access), and GET /models lists only the caller's models.
  3. Prompt injection (interceptors.promptInjection) in user and tool messages. The app's own system prompt and the model's earlier turns are not scanned.
  4. Credentials (interceptors.egressSecrets) anywhere in the prompt: provider keys, private keys and connection strings are blocked (403) before they reach the provider.
  5. Sensitive routing (llm.sensitive). When the prompt holds personal data (optionally only some categories) or one of the listed topics, the request goes to sensitive.model — an Ollama, vLLM or llama.cpp model you run — instead of the model asked for. The audit log gets a warn event with ruleId: llm:route:sensitive naming both models and why, so routed requests show up in the dashboard, and the usage event carries llm.routedFrom. If the local model has no route for the request's API (an Anthropic request with only an OpenAI-compatible local model), the request is refused rather than sent to the provider it was meant to avoid. Budgets count the routed model.
  6. Budgets. Requests, tokens and USD per user (or per key) or for the whole gateway, over fixed windows. A request is admitted while every matching budget has room; tokens and cost are known only after the answer, so the call that crosses a limit completes and the next one gets 429 with Retry-After.
  7. PII (interceptors.piiMasking) in message text, tool-call arguments and tool results is masked, or with mode: tokenize replaced by tokens the proxy turns back into real values in the answer — the provider never sees them, the user does. The text of Anthropic document blocks with a text or content source is masked too. Image data, ids and tool names are left alone; content the proxy cannot read is refused (see Failure behavior).
  8. Audit. A tool_call event (tool: llm:<model>, upstream: <provider>, labels: [llm], user, piiHits, and documents for PDFs, images and audio read — see below) and a tool_response event with llm: { model, inputTokens, outputTokens, costUsd }. Refusals are blocked events with ruleId llm:injection, llm:credential or the budget id.

Endpoints. POST /chat/completions, POST /responses (OpenAI Responses API) and POST /embeddings go to providers with api: openai; POST /messages to api: anthropic; GET /models lists the literal (non-glob) routes the caller may use. Responses go through the same steps as chat: instructions, message items (input_text, earlier output_text), function and custom tool call arguments, tool call outputs and stored-prompt variables are masked; reasoning items, item references and the provider's own tool calls are passed back as they are; input_file and input_audio are refused (415). With mode: tokenize, tokens are put back in the answer, but a conversation continued with previous_response_id holds tokens from an earlier request that this request cannot put back — send the history in input if the answer must show real values.

Streaming works for chat, Messages and Responses. A plain stream is relayed event by event; for OpenAI the proxy adds stream_options.include_usage to count tokens and drops that extra chunk if the client did not ask for it. When the answer has to be rewritten (PII tokens to put back, or maskResponses), it is rewritten as it streams: text — message content, tool-call arguments, Anthropic text, JSON and thinking deltas — is held only until a safe cut (a space with no digit on either side, so a grouped number such as an IBAN or a card is never split; a private key block until its END line; a line with an Adres: label until it ends) and rewritten together with the last 300 characters already sent, so a label like Pasaport No: still applies to a value in the next event. Whatever is still held goes out just before the choice's finish chunk or the block's stop event. For the Responses API, output_text, function-call argument and reasoning-summary deltas are held and rewritten the same way; each .done event, item, part and the final response.completed carry the same rewritten text, so the SDK's assembled answer matches the deltas. Provider errors are passed through unchanged, so SDK retries and error types keep working.

PDFs, images and audio in requests

With llm.documents.pdf: true, an inline PDF is read instead of refused: OpenAI file parts and Responses input_file parts with a data:application/pdf;base64, URL, and Anthropic document blocks with a base64 PDF source, in messages, the system prompt and tool results. The proxy extracts the PDF's text layer with @guardbee/mcp-document-scanner and puts that text through every step above, in the PDF's place: prompt injection, credentials, topic rules, sensitive routing and PII.

  • Clean: the PDF goes to the provider unchanged.
  • Personal data the proxy would mask: refused with 403 (ruleId: pii:document), since the proxy cannot rewrite a PDF and does not send it unmasked. The message names the categories, never the values. On a route with masking: off, or when llm.sensitive routes the request to your own model, the PDF is forwarded and its PII counted in piiHits. With llm.sensitive, a PDF holding personal data is exactly what gets routed in-house.
  • Injection, credentials, topic rules: the same refusals as for text (llm:injection, llm:credential, the rule's id).
  • Not readable in full: over maxBytes or maxPages, not really a PDF, encrypted or corrupt, slower than timeoutSeconds, or a page without a text layer while ocr is off. A warn event documents:unread names the reason, and the PDF stays unmaskable: 415 guardbee_unmaskable_content while masking applies (see Failure behavior). A PDF that was only partly read is never treated as checked.
  • Audit: the tool_call event's input holds [pdf "<name>": <bytes> bytes, sha256 <hash>] instead of the PDF. Its documents field lists each PDF read: { name, modality: "pdf", bytes, sha256, pages, chars, checks }, where checks names the checks that ran over its text (injection, credentials, topics, sensitive-routing, pii).

With llm.documents.images: true, inline images are read the same way, with OCR: OpenAI image_url and Responses input_image parts with a data:image/...;base64, URL, and Anthropic image blocks with a base64 source, in messages, the system prompt and tool results. The text OCR finds takes the image's place in every check, with the same outcomes as a PDF: a clean image is forwarded unchanged, one holding personal data the proxy would mask is refused with 403 pii:document, and one that cannot be read (tesseract.js not installed, data that is not an image, over maxBytes, slower than timeoutSeconds) is 415 guardbee_unmaskable_content while masking applies. The audit placeholder is [image "image <n>": <bytes> bytes, sha256 <hash>] and documents entries have modality: "image".

  • Install tesseract.js next to the proxy (npm i tesseract.js). Language data (languages, default Turkish and English) is downloaded once into ~/.cache/guardbee/tessdata; set langPath to a folder with the .traineddata files to keep the proxy offline.
  • OCR reads printed text, not everything a model sees. Handwriting, low-contrast or tiny text, text in photos and charts can be missed, and a model can still read what OCR did not. Treat images: true as a check that catches screenshots of documents, chats and forms, not as a guarantee that an image holds no personal data.
  • Cost: about 0.3 s per screenshot on a laptop CPU, on every request that carries one, more for large images; timeoutSeconds and maxBytes bound it.

With llm.documents.audio, Chat Completions input_audio parts (wav, mp3) are transcribed by the speech-to-text service you name, and the transcript goes through every check with the same outcomes: clean audio is forwarded unchanged, audio in which personal data is spoken is refused with 403 pii:document, and audio that cannot be transcribed (another format, data that is not that format, the service down, answering an error or slower than timeoutSeconds) is 415 guardbee_unmaskable_content while masking applies. The audit placeholder is [audio "audio <n>": <bytes> bytes, sha256 <hash>]; the transcript itself is not logged.

  • The service is any endpoint that takes OpenAI's POST /v1/audio/transcriptions (multipart file, model, response_format=json, optional language) and answers { "text": … }: whisper.cpp's whisper-server --inference-path /v1/audio/transcriptions, faster-whisper-server / Speaches, vLLM, or a provider's API.
  • The audio goes to that service as is, before any check, so run it where the data may go: on your own machine or network. Pointing it at a public API sends every voice message there.
  • Speech to text can mishear. Numbers read aloud ("on bir, sıfır sıfır…") may come back as words or with mistakes, so treat this as a check that catches clear speech, not a guarantee.
  • Cost: one transcription per audio part, a few seconds per minute of speech on a CPU; timeoutSeconds and maxBytes bound it.

Not covered yet: PDFs given by file id or URL (still 415), images given by URL or file id (forwarded unread: the proxy does not fetch them), person names found by the ner: model inside a PDF, image or transcript, and audio in the Responses or Realtime API. Reading a PDF costs time on every request that carries one; timeoutSeconds and maxPages bound it.

Embeddings

POST /embeddings masks every input text before it is embedded; the vectors come back unchanged. What to know before you index masked text:

  • Masking is deterministic here. Built-in values get their fixed placeholder ([TC-KİMLİK], ***@[EMAIL]), mode: tokenize is not used (a token is per request, so the same document would embed differently every time), and org block patterns are numbered per text, not per request. The same text always produces the same vector, whatever batch it is sent in.
  • Documents that differ only in a masked value embed the same. Two records that differ only by the person's name or ID number collapse to one vector, so similarity search cannot tell those people apart. Filter on your own ID field (metadata), not on vector similarity.
  • Index and query through the same path. A vector made from masked text is only comparable with other vectors made from masked text: send both the documents you index and the queries you search with through the gateway, with the same piiMasking settings.
  • The length limit is per element. Each element of input is checked against llm.maxTextChars; one element over it refuses the whole batch with 413. Token-id input ([[1, 2, 3]]) cannot be masked and is refused with 415 — send text.
  • Not applied to embeddings: prompt-injection scanning (a document about injection must still be indexable) and llm.sensitive routing (vectors from another model are not comparable with your index). Credential, topic, budget and access checks apply as usual.

Failure behavior

The gateway fails closed: when it cannot check or mask a request, the provider is not called. The refusal carries a stable code in the error body (error.code, in the OpenAI and the Anthropic error shape) and in the x-guardbee-error header, and a blocked event goes to the audit log. Neither the response nor the audit log ever holds the text that could not be masked — a detector's own error message is replaced by its error class.

StatusCodeWhenAudit ruleId
502guardbee_masking_failedA detector or scan threw, a block pattern ran over its time budget, or the name model (ner:) did not answer. Covers every check before the call: injection, credentials, topics, sensitive routing, masking.pii:masking-failed, pii:pattern-timeout, pii:ner-failed
415guardbee_unmaskable_contentThe request holds content the proxy cannot read: OpenAI input_audio or file parts, an Anthropic document with a PDF (base64, url or file) source, an unknown part or block type, or content that is not text or a list. With llm.documents.pdf, inline PDFs are read and only one that cannot be read in full is refused; with llm.documents.images and llm.documents.audio, so is an inline image or audio part that cannot be read. The message names what was found.pii:unmaskable
413guardbee_text_too_largeA single text (message, part, tool result) is longer than llm.maxTextChars (default 2,000,000 characters). The limit is per text, not per request: split the text into several messages or parts.pii:text-too-large

What is not refused: images (image_url, input_image, Anthropic image) are forwarded unchanged and, without llm.documents.images, are not inspected; in an assistant turn, blocks the provider itself produced (thinking, redacted_thinking, server_tool_use, *_tool_result) are passed back as they are. 415 and 413 apply only when the request is masked — with piiMasking.enabled: false, on a route with masking: off, or for a request routed in-house by llm.sensitive without mask: true, nothing is masked and nothing is refused for being unmaskable.

An answer that cannot be masked (maskResponses, or a failure while putting tokens back) is withheld: the text is replaced by [GuardBee: withheld, …], also mid-stream, and the event is logged with the same ruleIds.

Passthrough per route. masking: off on an llm.routes entry sends that route's prompts (and answers) unmasked. It has to be written out — there is no implicit passthrough — and every such request writes a warn event with ruleId: pii:masking-off naming the route; PII is still counted in piiHits. A topic rule with action mask still masks on that route.

In the MCP gateway a masking failure withholds the tool result: the client gets an MCP error (InternalError, Result withheld: …) with data: { guardbee: "guardbee_masking_failed" }, and no tool_response event is written.


A2A Gateway

Multi-agent systems call each other over A2A (Agent2Agent). With an a2a: section the gateway sits between them. An agent calls http://gateway:8787/a2a/<name> instead of the other agent's own URL; clients that discover agents can start from /a2a/<name>/.well-known/agent-card.json. The card is served with its URL rewritten to point at the gateway and with the gateway's auth scheme.

yaml
listen: { transport: http, host: 0.0.0.0, port: 8787, apiKeys: ["${GB_AGENT_KEY}"] }   # or listen.oidcupstreams: {}a2a:  agents:    research: { url: "http://research-agent.internal:9999/", headers: { authorization: "Bearer ${RESEARCH_AGENT_TOKEN}" } }    billing:  { url: "https://billing-agent.internal/a2a", allowGroups: [finance] }  injection: block        # or warn  maskRequests: true      # personal data masked before it reaches an agent  maskResponses: false  pushAllowHosts: []      # private webhook receivers you run yourself

What it enforces:

Who may callEvery caller authenticates with listen.apiKeys or listen.oidc; A2A has no open mode. Only configured agents can be reached, and allowGroups limits an agent to callers in those groups. Agent names are listed only to authenticated callers. The caller's token is never forwarded: the agent gets the headers configured for it.
Messages to an agentChecked for prompt injection: block refuses with a JSON-RPC error, warn logs it. Personal data in text and data parts is masked (maskRequests).
What agents send backTasks, artifacts, status messages and history are checked for indirect injection. Streamed responses (SendStreamingMessage, SubscribeToTask) are checked event by event, and the stream ends with an error before a bad event is forwarded.
Push-notification webhooksThe URL a remote agent will POST to is resolved. If it points at a link-local or metadata address (169.254.169.254), the request is always refused. Private and loopback addresses are refused unless the host is in pushAllowHosts. This applies to CreateTaskPushNotificationConfig, the 0.3 tasks/pushNotificationConfig/set, and the push config inside SendMessage.
AuditEvery call is a tool_call / tool_response event (tool: a2a.SendMessage, upstream: <agent>, the caller as user), with masking counts in piiHits. Refusals are blocked events with ruleId a2a:*. The events reach the same sinks, SIEM exporters and OTel traces as MCP traffic.

It works with the JSON-RPC binding of A2A 1.0 (SendMessage, GetTask, …) and 0.3 (message/send, tasks/get, …). The REST and gRPC bindings are not proxied, and the rewritten card lists only the JSON-RPC interface. The original card's signatures do not cover the rewritten card, so they are removed. Static checks of agent cards and webhook configs are in @guardbee/mcp-a2a-auditor. Full example: examples/proxy/a2a.yaml.

Coding-Agent Hooks

The proxy sees MCP traffic only. A coding agent's own tools — the shell, file reads, web fetch — never pass through it, so cat .env followed by curl -d @.env … is invisible to it. guardbee-hook applies the same policy to those tools through the agents' hook systems.

bash
npm i -g @guardbee/mcp-security-proxyguardbee-hook install --claude-code                 # ~/.claude/settings.json (PreToolUse + PostToolUse)guardbee-hook install --cursor                      # ~/.cursor/hooks.json (shell, MCP, file reads)guardbee-hook install --codex                       # ~/.codex/hooks.json (PreToolUse + PostToolUse; trust them under /hooks)guardbee-hook install --gemini-cli                  # ~/.gemini/settings.json (BeforeTool + AfterTool; turns hooks on)guardbee-hook install --windsurf                    # ~/.codeium/windsurf/hooks.json (commands, file reads/writes, MCP)guardbee-hook install --claude-code --project       # .claude/settings.json in this repoguardbee-hook install --cursor --config ~/.guardbee/guardbee-proxy.yaml --fail-closedguardbee-hook uninstall --claude-code               # removes only GuardBee's entries

Install keeps every other setting and hook, replaces its own entries when run again, and leaves a .guardbee-backup copy. --dry-run prints the result without writing. Run it from a global install: a path in the npx cache can be cleared.

Labels. Built-in tools get the same four labels as MCP tools, from what they do:

ToolLabel
Read/Grep/Glob of .env*, ~/.ssh/, id_rsa, ~/.aws/credentials, .kube/config, .npmrc, .netrc, *.pem, *.key, *.tfstate, credentials*.json, …sensitive
Bash reading those files, printenv, keychain and cloud-token commandssensitive
Bash with curl, wget, nc, scp, ssh, git push, npm publish, docker push, gh api, aws s3 cp, …egress (curl/wget/git clone also untrusted)
Bash with rm -rf, git push --force, git reset --hard, DROP TABLE, terraform destroy, kubectl delete, …destructive
WebFetch, WebSearchuntrusted + egress (the URL or query can carry data out)
mcp__server__toolthe proxy's name heuristics

What is checked, before the tool runs: a credential in a call that can send data out (egress:credential), your rules — with upstream set to claude-code or cursor, tool to the tool name (Bash, Read, WebFetch, mcp__github__create_pull_request), args to its input and topic to its topics — and the lethal-trifecta check: once the session has read untrusted content and sensitive data (a secret file, or PII in any output), an egress call is denied (taint.mode: strict), asked about (approve) or logged (warn). After it runs: PII in the output taints the session, instructions hidden in the output stop the agent (decision: block with the reason), and topic rules apply to the output.

yaml
rules:  - { id: ask-destructive, match: { upstream: claude-code, label: destructive }, action: approve }  - { id: no-prod,         match: { tool: Bash, args: { command: "*--context prod*" } }, action: deny }
  • The hook never answers "allow" for you: no objection leaves the agent's own permission prompts in place. deny becomes permissionDecision: deny (Cursor: permission: deny), approve becomes ask.
  • defaults.action is not applied to agent tools — a deny default meant for MCP tools would otherwise block every shell command. Write rules for what to stop.
  • Each hook call is a separate process (about 0.1–0.2 s). The session's taint is kept per agent session under ~/.local/state/guardbee/hooks ($XDG_STATE_HOME) and expires after a day. The audit log goes to your configured file sink, or ~/.local/state/guardbee/hooks/audit.jsonl — never to the agent's terminal — and to the dashboard when audit.dashboard is set.
  • Config: --config, then GUARDBEE_PROXY_CONFIG, then ~/.guardbee/guardbee-proxy.yaml, then built-in defaults. A file used only for hooks has a hooks: {} section and no upstreams.
  • On an internal error the tool runs (fail open), unless the hook was installed with --fail-closed.
  • Output of built-in tools is not rewritten: masking a file Claude reads would break its later Edit calls, which must match the file exactly.

MCP Tools

The proxy does not register tools of its own. It forwards the target server's tools and applies the interceptor chain on every call.


Interceptors

Prompt Injection Detector

27 rules from @guardbee/guard-core, applied to tool arguments, tool results, resources and prompts:

  • Block (13 precise, high/critical): instruction overrides in English and Turkish ("ignore all previous instructions", "önceki talimatları yok say"), DAN mode, "bypass your safety guardrails", chat-template tokens (<|im_start|>, [INST]), <system> tags, text hidden in Unicode tag characters, instructions in CSS-hidden elements or HTML comments, markdown image exfiltration beacons, requests to reveal the system prompt (English and Turkish), "send the user's data to https://…".
  • Warn only (14): generic phrases that also appear in ordinary text — "act as", "you are now", "developer mode", "jailbreak", "override policy", "sen artık" — and medium-severity signals such as zero-width or bidi control characters and a line-start System: label.
  • Base64: runs that decode to readable text are decoded and checked against the block rules, so an instruction cannot hide in an encoded blob.

Since 1.1, a broad phrase alone no longer blocks: in 1.0 "use jailbreak mode" or "act as a reverse proxy" in a tool result was blocked; now it is logged and the result carries a warning.

PII Masker

Data TypeCheckExample InputOutput
National ID (TC Kimlik No)official checksum10000000146[TC-KİMLİK]
IBAN (TR)mod-97TR330006100519786457841326TR**[IBAN]
Card numberLuhn4111 1111 1111 1111****-****-****-[KART]
Email—[email protected]***@[EMAIL]
Phone (TR)mobile / landline / 850 prefix; (0532) … too; a bare 5xx number only after a word like numaram, tel, cep0532 123 45 67+90-***-***-**[TELEFON]
Tax number (VKN)check digit, needs a VKN / Vergi No labelVKN: 1234567890VKN: [VKN]
Provider keys22 key and token formats from secret-scanner (AWS, GitHub, Stripe, OpenAI, Anthropic, Slack, …)ghp_…, AKIA…[API-KEY]
Other API keyssk_/api_/token_… prefix, letters and digitsapi_k3y9x8…[API-KEY]
Private keywhole BEGIN…END block-----BEGIN … PRIVATE KEY-----[PRIVATE-KEY]
Connection stringuser:password in the URL (localhost excluded)postgres://app:pw@db/prod[CONNECTION-STRING]
JWT—eyJhbGc...[JWT-TOKEN]
Passportneeds a Pasaport / Passport labelPasaport No: U12345678Pasaport No: [PASAPORT]
SGK / SSK / Bağ-Kur noneeds the labelSGK sicil no: 1234567890SGK sicil no: [SGK-NO]
MERSİS noneeds the label; 16 digitsMERSİS No: 0123 4567 8901 2345MERSİS No: [MERSİS]
Date of birthneeds Doğum tarihi / DOB / Date of birth, or doğumlu right after; digits or a month nameDoğum tarihi: 12.05.1990, 3 Mart 1979 doğumluDoğum tarihi: [DOĞUM-TARİHİ]
Postal addressneeds Adres: / Address: (a number or street word; not IP/e-mail/web/wallet addresses), or no label in the Mah. … Cad./Sok. … No: formAdres: Moda Cad. No 5, Gazi Mah. Akasya Cad. No:5 D:3Adres: [ADRES]
KEP address@….kep.tr[email protected]***@[KEP]
Person name (needs ner:)a local Turkish NER model (@guardbee/ner)Ayşe Yılmaz aradı[KİŞİ] aradı
IP address (opt-in)valid IPv4 octets; IPv6 with :: or 8 groups10.0.3.17[IP]
MAC address (opt-in)6 hex pairs, one separatoraa:bb:cc:dd:ee:ff[MAC]
Vehicle plate, TR (opt-in)province 01–81, letter/digit combination, not units (16 GB 2400)34 ABC 123[PLAKA]

IP and MAC addresses and plates are everywhere in infrastructure output, so they are off unless you list them: interceptors.piiMasking.include: [ip_address, mac_address, plate_tr]. Every category is counted in piiHits and the dashboard's KVKK report.

Choosing what to mask. piiMasking.preset picks the detectors that mask, and enable / disable add or drop detectors by name (the names in piiHits, e.g. tc_kimlik, email, github_pat). upstreams.<name> replaces the selection for one upstream or LLM provider. Detection does not narrow: every default detector still counts in piiHits and taints the session, only masking follows the selection. Opt-in detectors are in no preset; list them in enable (or include).

PresetMasks
ALL (default)every detector above except the opt-in ones, plus credentials
KVKKpersonal data only, no credentials
KVKK_SENSITIVEKVKK plus the nine KVKK Art. 6 special categories (below)
IDENTITYperson name (with ner:), postal address, date of birth
IDENTIFIERSTC kimlik, VKN, MERSİS, SGK, passport, IBAN, card, phone, KEP, e-mail
SECRETSAPI keys, tokens, private keys, connection strings, JWTs
yaml
interceptors:  piiMasking:    enabled: true    preset: KVKK    disable: [address]    upstreams:      ops: { preset: SECRETS, enable: [ip_address] }   # an infrastructure server

An unknown preset, detector or upstream name fails config validation.

Your own patterns. piiMasking.blockPatterns masks what no detector knows (a case number, a client code): a regex, or { pattern, category }; the category becomes a numbered placeholder (CLIENT_NO → <CLIENT_NO_1>, the same value gets the same number) and the piiHits key. A built-in detection wins an overlap, and block patterns apply whatever the preset. piiMasking.allowPatterns leaves a detected value unmasked when its whole text matches (your public support address); every time one does, a warn event with ruleId: pii:allow goes to the audit log, since it lowers protection.

yaml
interceptors:  piiMasking:    enabled: true    blockPatterns:      - { pattern: "CL-\\d{6}", category: CLIENT_NO }      - "DOSYA/\\d{4}"                       # category CUSTOM    allowPatterns: ["destek@acme\\.com\\.tr"]

Each pattern gets a time budget (100 ms per masking call), so a catastrophic regex cannot hang the proxy, and the two kinds fail in opposite directions on purpose: a block pattern over budget withholds the result (pii:pattern-timeout; the LLM gateway answers 502), an allow pattern over budget is skipped and the value stays masked (pii:allow-skipped). At most 25 patterns, 200 characters each; an invalid regex, or a category named like a built-in detector (EMAIL, IBAN, SECRET, …), fails config validation. An LLM gateway request can add its own block patterns in a guardbee body field ({ "guardbee": { "blockPatterns": [...] } }, removed before the provider sees the body); they are added to the configured ones, and a request with allowPatterns is refused with 400: a request can only add masking.

A number that fails its checksum (an order number, a tracking code) is left as is.

Person names. No regex finds a name, so person_name comes from a model: @guardbee/ner, a Turkish BERT NER model that runs offline (186 MB, about 8 ms per text on a laptop CPU). It is off until the config has a top-level ner: section. That section is deployment, not policy: a dashboard policy cannot set or move it.

yaml
ner:  url: http://127.0.0.1:8765        # guardbee-ner serve (sidecar)  tokenEnv: GUARDBEE_NER_TOKEN      # its bearer token, from the environment  timeoutMs: 5000                   # default# or in this process (install @guardbee/ner next to the proxy):# ner:#   modelDir: /opt/guardbee/ner     # default: guardbee-ner download's cache

Run the sidecar with npx @guardbee/ner download then npx @guardbee/ner serve; it listens on loopback. A sidecar on another host must be https://, since the texts would otherwise cross the network in the clear. The model checks its files against pinned SHA-256 hashes before it loads.

Before masking, every string in the value is sent to the model once, then masked in the same pass as the regex detectors, so person_name follows preset / enable / disable, mode: tokenize (<pii:person_name:…>) and allow patterns like any detector. Names are masked in tool results, structuredContent, resources, prompt templates, sampling answers, and LLM gateway requests (embeddings too), and they count for llm.sensitive routing. With llm.maskResponses, names in the model's answers are masked too. In a stream, text is not cut next to a capitalised word, so a name reaches the model whole. If the model fails on an answer, the rest of that answer is withheld ([GuardBee: withheld, name detection failed]). Coding-agent hooks count names in tool output for the session's taint; if the model fails there, the output is counted as personal data. A hook process starts for every call, so give hooks a sidecar (ner.url) rather than modelDir. Names are not looked for when masking is off.

A model span that lies wholly inside a special-category term (Huntington in Huntington geni) is left to that category when KVKK_SENSITIVE runs.

When ner: is set and the model cannot answer (sidecar down, timed out, an error), nothing goes out: the tool result is withheld and an LLM request is refused with 502 guardbee_masking_failed, ruleId: pii:ner-failed. Masking never falls back to regex only, because the text may hold a name that only the model would find.

The model misses some names and flags some words. A capitalised word at the start of a sentence that is also a name (Barış, Umut) can go either way, and all-lowercase chat (elifle konuştum) is often missed. Measured numbers are in pii-bench.

KVKK Art. 6 special categories. preset: KVKK_SENSITIVE (or enable by name) also masks health, religion and belief, ethnic origin, political opinion, union membership, criminal record, sex life, biometric and genetic data: health → [SAĞLIK], religion_belief → [DİN-İNANÇ], ethnic_origin → [ETNİK-KÖKEN], political_opinion → [SİYASİ-GÖRÜŞ], union_membership → [SENDİKA], criminal_record → [SABIKA], sex_life → [CİNSEL-HAYAT], biometric → [BİYOMETRİK], genetic → [GENETİK]. They are found with a Turkish and English term list per category and a context rule. A term is masked only when the same text is about a person: a name the model found, a personal identifier, or a word that points at someone (hastanın, çalışan, babam, a first-person verb). Diyabet hastaları için rehber is left as it is; Çalışan migren nedeniyle raporlu becomes Çalışan [SAĞLIK] nedeniyle raporlu. The span grows over the phrase around the term, so metal işçileri sendikası üyesi is masked whole.

These detectors are opt-in, because a term list cannot tell a medical knowledge base from a patient file. The bias is conservative: once a text is about someone, every listed term in it is masked. They find words, not meaning, so a term that is not on the list is missed. Use them with ner:, since without names many texts are about a person only through their name. In pii-bench the hand-written challenge set gives F1 86–100 per category with the name model and 40–89 without, with no false alarms on its clean lines. They add under 0.15 ms per text.


Configuration

Configurable via environment variables:

VariableDefaultDescription
PROXY_MODEblockblock or warn
PROXY_LOGconsoleconsole or file
PROXY_LOG_PATH./proxy-audit.jsonlLog file path
PROXY_PII_MASKtrueEnable PII masking
PROXY_INJECTION_CHECKtrueEnable injection checking

License

MIT — GuardBee

Feedback

Questions, ideas, or a false positive? Start a thread in GitHub Discussions or open an issue. If GuardBee is useful to you, a ⭐ on the repository helps others find it.

來源:packages/security-proxy/README.md,提交 8cc1e03

工具

0
SourceWeft 尚未在沙箱中啟動過該軟體包,因此其工具未知。

版本歷史

24
  1. v1.37.1最新Nov 3, 2026
  2. v1.37.0Nov 1, 2026
  3. v1.36.0Oct 31, 2026
  4. v1.35.0Oct 31, 2026
  5. v1.34.0Oct 31, 2026
  6. v1.33.0Oct 30, 2026
  7. v1.32.1Oct 30, 2026
  8. v1.31.0Oct 29, 2026
  9. v1.28.0Oct 26, 2026
  10. v1.27.0Oct 22, 2026
  11. v1.26.0Oct 22, 2026
  12. v1.25.1Oct 21, 2026
  13. v1.25.0Oct 20, 2026
  14. v1.24.1Oct 20, 2026
  15. v1.23.0Oct 19, 2026
  16. v1.22.0Oct 19, 2026
  17. v1.20.0Oct 16, 2026
  18. v1.19.0Oct 15, 2026
  19. v1.18.0Oct 14, 2026
  20. v1.17.0Oct 14, 2026
  21. v1.14.0Oct 14, 2026
  22. v1.13.2Oct 13, 2026
  23. v1.13.1Oct 11, 2026
  24. v1.13.0Oct 9, 2026

評分

已滿足適用檢查項的 50%

該評分衡量此收錄條目的完整程度,依據註冊中心條目、端點與公開來源計算得出。它不是對伺服器本身的評價,也不表示伺服器安全或目前可用:可連通性是帶日期的檢查結果,評分也不包含安裝量或使用者評價。

  • 近 30 天內可連通25 分

    請保持端點能夠回應 MCP 握手;SourceWeft 每 30 天探測一次。

  • 已宣告認證方式10 分

    請在註冊中心條目中宣告憑證請求標頭,或保持端點能夠回應探測,以便判定其認證方式。

  • 已列出工具15 分

    請讓伺服器在無憑證時回應 tools/list,或發布可在沙箱中無憑證啟動的軟體包。

  • 描述必需15 分
  • 圖示10 分
  • 文件15 分
  • 授權條款10 分