
Security Proxy
io.github.4hmetuyar由 GitHub 账号 4hmetuyar 发布v1.4.0更新于 Nov 6, 2026
MCP gateway: many servers, one policy, lethal-trifecta blocking, PII masking, audit log
概览
一个本地代理,置于其他 MCP 服务器之前,用于拦截提示注入、遮蔽个人数据、执行策略规则并写入哈希链审计日志。
- 可以做什么
- 把多个 MCP 服务器置于同一代理之后,工具以 upstream__tool 形式暴露
- 对工具参数、结果、资源和提示中的提示注入进行拦截或告警,并固定工具定义以防后续被篡改
- 在响应中遮蔽个人数据、密钥、私钥和连接串,并可按工具设置拒绝、遮蔽、告警或需人工批准
- 跨服务器追踪致命三元组,阻断会凑齐条件的对外发送调用
- 在操作系统沙箱中运行上游服务器,限制磁盘写入、隐藏凭据并限制网络
- 把审计日志生成签名安全报告、合规章节和 CycloneDX AIBOM
- 适用场景
- 当助手已经连接第三方或不可信的 MCP 服务器,而你想在中间加一层策略时使用:注入过滤、个人数据遮蔽、对破坏性或外发工具的人工批准,以及供安全团队或审计使用的审计记录。
- 运行要求
- 以 npm 包形式在本地通过 stdio 运行(需 Node.js,可全局安装或用 npx)。它会启动 -- 之后指定的目标服务器,或在网关模式下读取 guardbee-proxy.yaml 配置。可选功能需额外准备:按用户认证需要 OIDC 提供方与客户端密钥,可选分类器或裁判需要本地模型,沙箱需要 docker、podman、bwrap 或 sandbox-exec 等运行时。
安装
在 SourceWeft 中
- 打开 控制台中的 Security Proxy,将其添加到工作区。
- 为需要使用其工具的对话启用该服务。
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=1it sends the server, tool, success, duration and short parameter values such as a file path; longer strings and fields likecontent,tokenorpasswordare replaced with a placeholder. See@guardbee/mcp-telemetry.
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/listis 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-hookapplies 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
Add to claude_desktop_config.json:
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:
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:
-
Tools and prompts appear as
<upstream>__<tool>. -
Toxic flow (lethal trifecta): each tool is labeled
untrusted,sensitive,egressordestructive(name/description heuristics, overridable underlabels). Once a session has read untrusted content (anuntrustedtool or any resource) and sensitive data (asensitivetool or any PII in a result), anegresscall is blocked instrictmode and only logged inwarnmode. 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) andargs.maskforces PII masking for that tool even when masking is off, andmask.fieldsalso blanks the named JSON keys in text andstructuredContent. A rule'sallowdoes 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 whenminMatches(default 1) different terms appear. Matching folds case and Turkish letters (BİRLEŞME=birlesme), and a term may take a suffix (maaşfindsmaaşları,satın almafindssatı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:denywithholds it,maskforces PII masking andmask.fields,warnlogs,approveasks (once: not again for the answer of a call it already approved). In the LLM API gateway topic rules apply to the whole prompt, withtool: llm:<model>;approverefuses there, since an API call has no one to ask. -
Tool exposure (
tools):exposeis an allowlist of globs on the name the agent sees (absent: every tool), andhideremoves tools after it. A hidden tool is left out oftools/list, so the model never reads its description (a smaller poisoning surface and fewer tokens), and a call to it is refused and counts towardanomaly.repeatedBlocks.descriptionsreplaces a tool's description with one you wrote; drift detection keeps watching the server's own. Unlike adenyrule, 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 undertools.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 areadOnlyHintthat the description contradicts. Inblockmode (default; followspromptInjection.actionwhen 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 asruleId: poisoning:<pattern>. The rules are the ones@guardbee/mcp-tool-poisoning-scanneruses, now shared through guard-core. -
Credentials leaving (
interceptors.egressSecrets, on by default): before anegresstool 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; followspromptInjection.actionwhen unset) refuses the call,warnlogs it; the audit event has the credential masked either way. A PII token stays a token for an egress tool unlesspiiMasking.detokenizeForEgress, so only then is its value checked.allowToolsexempts 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);includeContextis forced tonone, so a server never gets the conversation;maxTokensis 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: trueasks 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 usetool: sampling:<upstream>andruleId: sampling:<check>. The capability is offered when the proxy starts, so turning it on or off needs a restart. -
Approval:
approve(a rule action, ortaint.mode: approvefor 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 withinapproval.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]andaudit.dashboardset, 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 ortimeoutSecondspasses. Channels are tried in order; the first one that can ask gives the answer. -
Data-based taint (
taint.basis: data): the defaultcapabilitybasis blocks every egress call once a session holds untrusted and sensitive content. Withdata, 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; keepcapabilitywhere that matters. Incapabilitymode 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 ofsensitivetools in 300s (enumerating a store record by record; re-reading the same record does not count).destructiveBurst— more than 5destructivecalls 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.warnlogs the first trip of each check;blockrefuses calls over the limit until the window slides, and after repeated blocks refuses every later call in the session. Events arewarn/blockedwithruleId: 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 — whereinterceptors.anomalywatches one session, a quota holds when a user opens ten. Each entry hasmatch(toolanduserglobs,upstream,label,group; all optional),limit,windowSecondsandper:user(default; the OIDC user, or the session without one),session, orgateway(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, logsruleId: quota:<id>, and does not count towardanomaly.repeatedBlocks. Counters live in the proxy process: several replicas each keep their own. -
Policy from the dashboard:
policy: { source: dashboard, refreshSeconds: 60 }(withaudit.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 nexttools/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 optionaltesseract.js(and@napi-rs/canvasfor scanned PDFs) are installed. A document holding personal data or a credential is replaced by a notice inblockmode (ruleId: documents:pii) or logged inwarnmode; its counts go intopiiHitsand taint the session like PII in text. A document that could not be read (OCR not installed, overmaxBytes) is logged asdocuments: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 assecret). Counts only, never the values, and counted even with masking off. The dashboard builds its KVKK report from them. -
Tokenize: with
interceptors.piiMasking.mode: tokenizethe 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 anegresstool unlesspiiMasking.detokenizeForEgress: true. -
Prompts (
prompts/get) get the same injection scan and PII masking as tool results. -
interceptors.definitionDrift.recheck: on-changeskips the per-calltools/listand re-checks only after a server sendstools/list_changed, which the proxy also passes on to the agent. -
Build provenance (
provenance,warnby default): before a stdio upstream starts, the proxy resolves the packagenpx/pnpm dlx/bunxwould 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'sintegrity), signed by a workflow of the repository it names, which must match the package'srepositoryfield. 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.warnstarts unverified packages and logsruleId: provenance:unsigned | invalid | error | unsupported;requirerefuses 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: trueuses 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), anduvx pkgruns asuvx 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 forghcr.io/<owner>/…, or matchprovenance.signers("docker.io/acme/*": "https://github.com/acme/") elsewhere, and the command runs pinned toimage@sha256:….--format sarifwrites the findings as SARIF 2.1.0 for code scanning. Limits: many packages publish without provenance (they show asunsigned, as do images signed another way than cosign, such as Docker Hub'smcp/images); a private npm registry is used throughprovenance.registryornpm_config_registry, a PyPI mirror throughprovenance.pypiIndex(it needs the integrity API), and private images are not supported (anonymous registry tokens only); the single-server--form does not check. -
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.fileto 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.blockrefuses the call,warnlogs 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 withaudit.includePayloads. Manage them withguardbee-proxy pins list | approve <upstream> [tool] | reset [upstream] [tool], and seed them from CI withguardbee-proxy pins import <baseline.json> --upstream <name>(anmcp-rug-pull-detectorbaseline: 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: trueto log them. Check a log withguardbee-proxy verify-audit ./guardbee-audit.jsonl. -
guardbee-proxy validate --config guardbee-proxy.yamlchecks a config without starting any server. -
Without
--config, the proxy also picks up./guardbee-proxy.yamlorGUARDBEE_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:
What every runtime enforces:
- Read-only disk. Writes go only to
fs.readWriteand 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,.envfiles, the proxy's own pin and provenance files, andfs.deny. - No host environment. Only
PATH,LANG,LC_ALL,TERM,TZ, the names inenvand the upstream'senv. network: nonecuts the network (the package must then already be installed: an image that has it, or a run withopenfirst).network: allowlistwithallowHosts(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 (unlessblockPrivate: false) private or loopback address is refused, DNS rebinding included. The sandbox getsHTTP_PROXY/HTTPS_PROXY(andNODE_USE_ENV_PROXY,npm_config_*proxy), which the upstream'senvcannot 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: listregistry.npmjs.orgfornpx,pypi.organdfiles.pythonhosted.orgforuvx.
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-execon Linux,limitswithout a container, areadWriteof~or/, a path inside a credential store) stops the gateway with the reason, andguardbee-proxy validatelists the same problems. Start failures are audit eventsruleId: sandbox:invalid | sandbox:failed. guardbee-proxy init --sandbox [runtime]puts every imported stdio server in a sandbox (default:sandbox-execon macOS,bwrapon Linux when installed, otherwisedocker), with the directories it was given as arguments writable.guardbee-proxy init --gradesprints 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 Cleaves 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-execis deprecated by Apple but still the macOS mechanism; it does not limit CPU or memory.bwrapneeds 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,.envfiles andfs.denyare hidden, everything else stays readable (use a container for an allowlist).network: allowlistfilters 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: usedockerorpodman.
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.
- 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
tokenEndpointathttps://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,audiencethe target authorization server's audience.
Per-user OAuth. For third-party services (GitHub, Google, Atlassian…), each user connects their own account once.
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=Strictcookie; - the authorization request uses PKCE S256 and a single-use
state; - there is one exact redirect URI;
- the
issin 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.
- 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 }, orerrorwhen it failed. Its blocks haveruleId: classifier:injection(llm:injection-classifierin the LLM gateway). The CLI scanner takes--classifierand reportsclassifierModelandclassifierScorein 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.
- Raise, not clear. The judge sees only what the rules left open: a
warnon tool arguments or results (withscope: results, also every result the rules let through). A confidentmaliciousverdict raises it to the configured action (interceptors.*.action, usuallyblock, auditruleId: judge:injection). Anything else keeps the rules' decision. Abenignverdict clears a warning only withmayClear: 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 inflexString1. 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:7bcatches all of them with no false positives, at about 1 s per call on a laptop;qwen2.5:3bcatches 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.
- 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-reportchecks a.json,.htmlor.mdreport; pass--pubkeyto 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_maptool. - 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:
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.
-
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.apiKeysorlisten.oidcis a config error. Keys are compared in constant time, and a wrong or missing key gets401. -
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 as169.254.169.254),0.0.0.0and reserved ranges are always refused, in any encoding (octal/hex IPv4, IPv4-mapped or NAT64 IPv6).blockPrivaterefuses loopback and private addresses (RFC 1918, CGNAT,fc00::/7); unset, it follows the configured URL: a server onlocalhostor a private address may stay there, a public one may not reach your network.mode: allowlistwithallowHosts(*.example.comglobs) limits the hosts. Refused connections are audit eventstype: blocked,ruleId: network:private | network:metadata | network:reserved | network:host, withdestination(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 — sogroupmatches in rules, quotas,llm.accessand 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.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.oidcwith them, andlisten.ldapfor 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, orjwksUri), the issuer, theaudience(required: a token minted for another service is refused) and the lifetime. The user comes fromuserClaim(defaultsub;emailis common) and the groups fromgroupsClaim(defaultgroups, a list or a space-separated string). Rules can matchuser(glob, e.g."*@contractor.com") andgroup, and every audit event carriesuser. A session belongs to the user who opened it: another user's token gets403on it. A401carriesWWW-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 setoidc.resourceto 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 (unlessincludePayloads) events as the local log.Splunk gets HEC events (
sourcetype: guardbee:audit). Elasticsearch gets_bulkcreateoperations with the event hash as_id, so a retried batch never duplicates (a409counts as sent), and@timestamp. Syslog is RFC 5424 over UDP, TCP or TLS (certificates always verified;caFilefor 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 putsact,suser,rt,msgandtool/upstream/ruleId/sessionId/piiHits/labelsincs1–cs6; severity 8 forblockedandtoxic_flow, 5 forwarnandapproval, 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. Withstate.backend: redisevery 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,onErrordecides:local(default) counts in the process until it is back,fail-openlets calls through uncounted,fail-closedrefuses 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. -
GET /healthzreturns{"ok": true, "sessions": N, "contexts": M}(2025-era sessions and 2026-07-28 contexts). Sessions and contexts abovemaxSessionsget503. -
Dashboard:
audit.dashboardsends every audit event, batched, to the GuardBee dashboard with a workspace API key taken fromapiKeyEnv. 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. -
initnow also moves Streamable HTTP servers (url+headers) behind the proxy. Legacy SSE servers stay in the client config and are reported.
Docker:
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 2025initializehandshake. Pin a revision per upstream withprotocol:On stdio,
autostarts the server once more for a moment to probe it; setprotocol: 2025-11-25for a server that must not be started twice. A server in adockerorpodmansandbox defaults to2025-11-25; withautoor2026-07-28it 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-Contextheader (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 afterlisten.contextIdleSeconds(default 1800) without a request; sessions and contexts together are capped bylisten.maxSessions. Discovery andsubscriptions/listenrequests (recognised from theMcp-Methodheader) 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 signedrequestStatebound 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 whenapproval.channelshas one, and are refused otherwise. Behind a load balancer, give every replica the same key so any of them can check the answer: -
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 initleaves SSE servers out and points at the/mcpendpoint 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.
What every request goes through, in order:
- Key.
Authorization: Bearer(OpenAI SDKs) orx-api-key(Anthropic SDKs). A gateway key fromllm.keysgives the user and groups; otherwiselisten.apiKeysandlisten.oidctokens are accepted too. Whenllm.keysis set andlistenhas no keys, an unknown key is always refused — even on loopback. - Route and access. The model name picks the provider; a model no route serves gets
404.llm.accessdecides which models the caller may ask for: every entry matching the user (glob) or a group adds its model globs; with no entry matching,accessDefaultapplies. A refused model gets403(ruleId: llm:access), andGET /modelslists only the caller's models. - Prompt injection (
interceptors.promptInjection) in user and tool messages. The app's own system prompt and the model's earlier turns are not scanned. - Credentials (
interceptors.egressSecrets) anywhere in the prompt: provider keys, private keys and connection strings are blocked (403) before they reach the provider. - Sensitive routing (
llm.sensitive). When the prompt holds personal data (optionally only somecategories) or one of the listedtopics, the request goes tosensitive.model— an Ollama, vLLM or llama.cpp model you run — instead of the model asked for. The audit log gets awarnevent withruleId: llm:route:sensitivenaming both models and why, so routed requests show up in the dashboard, and the usage event carriesllm.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. - 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
429withRetry-After. - PII (
interceptors.piiMasking) in message text, tool-call arguments and tool results is masked, or withmode: tokenizereplaced by tokens the proxy turns back into real values in the answer — the provider never sees them, the user does. The text of Anthropicdocumentblocks 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). - Audit. A
tool_callevent (tool: llm:<model>,upstream: <provider>,labels: [llm],user,piiHits, anddocumentsfor PDFs, images and audio read — see below) and atool_responseevent withllm: { model, inputTokens, outputTokens, costUsd }. Refusals areblockedevents withruleIdllm:injection,llm:credentialor 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 withmasking: off, or whenllm.sensitiveroutes the request to your own model, the PDF is forwarded and its PII counted inpiiHits. Withllm.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
maxBytesormaxPages, not really a PDF, encrypted or corrupt, slower thantimeoutSeconds, or a page without a text layer whileocris off. Awarneventdocuments:unreadnames the reason, and the PDF stays unmaskable:415 guardbee_unmaskable_contentwhile masking applies (see Failure behavior). A PDF that was only partly read is never treated as checked. - Audit: the
tool_callevent'sinputholds[pdf "<name>": <bytes> bytes, sha256 <hash>]instead of the PDF. Itsdocumentsfield lists each PDF read:{ name, modality: "pdf", bytes, sha256, pages, chars, checks }, wherechecksnames 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.jsnext to the proxy (npm i tesseract.js). Language data (languages, default Turkish and English) is downloaded once into~/.cache/guardbee/tessdata; setlangPathto a folder with the.traineddatafiles 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: trueas 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;
timeoutSecondsandmaxBytesbound 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(multipartfile,model,response_format=json, optionallanguage) and answers{ "text": … }: whisper.cpp'swhisper-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;
timeoutSecondsandmaxBytesbound 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: tokenizeis 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
piiMaskingsettings. - The length limit is per element. Each element of
inputis checked againstllm.maxTextChars; one element over it refuses the whole batch with413. Token-id input ([[1, 2, 3]]) cannot be masked and is refused with415— send text. - Not applied to embeddings: prompt-injection scanning (a document about injection must still be indexable) and
llm.sensitiverouting (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.
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.
What it enforces:
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.
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:
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.
- The hook never answers "allow" for you: no objection leaves the agent's own permission prompts in place.
denybecomespermissionDecision: deny(Cursor:permission: deny),approvebecomesask. defaults.actionis not applied to agent tools — adenydefault 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 whenaudit.dashboardis set. - Config:
--config, thenGUARDBEE_PROXY_CONFIG, then~/.guardbee/guardbee-proxy.yaml, then built-in defaults. A file used only for hooks has ahooks: {}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
Editcalls, 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
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).
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.
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.
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:
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,提交 952a0bf
工具
0版本历史
4- v1.4.0最新Oct 2, 2026
- v1.3.0Oct 2, 2026
- v1.1.1Sep 30, 2026
- v0.3.1Sep 29, 2026
评分
已满足适用检查项的 50%
该评分衡量此收录条目的完整程度,依据注册中心条目、端点与公开来源计算得出。它不是对服务器本身的评价,也不表示服务器安全或当前可用:可连通性是带日期的检查结果,评分也不包含安装量或用户评价。
- 近 30 天内可连通25 分
请保持端点能够响应 MCP 握手;SourceWeft 每 30 天探测一次。
- 已声明认证方式10 分
请在注册中心条目中声明凭据请求头,或保持端点能够响应探测,以便判定其认证方式。
- 已列出工具15 分
请让服务器在无凭据时响应 tools/list,或发布可在沙箱中无凭据启动的软件包。
- 描述必需15 分
- 图标10 分
- 文档15 分
- 许可证10 分