Version documented:
@kryptosai/mcp-seatbeltv0.4.1 Source layout: TypeScript, ESM ("type": "module"), Node.js, compiled withtsctodist/Entry point:src/index.ts(CLI via commander + library re-exports)
mcp-seatbelt is a runtime security proxy for the Model Context Protocol (MCP). AI agents (Cursor, Claude Desktop, VS Code Copilot, ChatGPT Desktop, Codex, JetBrains IDEs, Windsurf) execute tool calls against MCP servers with the ambient privileges of the user — filesystem, shell, network, and environment access — with no built-in authorization layer. Seatbelt closes that gap in two phases: a discovery phase (init / check / report) that finds every MCP server configured on the machine, scores its risk with 13 heuristic rules, and generates a YAML policy; and an enforcement phase (proxy) that stands up a local HTTP proxy in front of each upstream server and passes every JSON-RPC message through a 12-stage request pipeline — authentication → rate limiting → forensic capture → honeytoken tripwire → protocol validation → circuit breaker → policy interception → attack-chain tracking → schema/path validation → RBAC → threat intel → upstream dispatch with DLP, honeytoken injection, and tool-list filtering on the response path. The result is zero-trust, policy-as-code guardrails between AI clients and their tools, with a tamper-evident HMAC-signed audit trail for everything the pipeline observes.
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ AI Client │ JSON-RPC│ mcp-seatbelt │ JSON-RPC│ Upstream MCP │
│ (Cursor etc)├────────►│ proxy :9420 ├────────►│ Server │
│ │◄────────┤ 12-stage pipe │◄────────┤ (stdio/http/sse)│
└─────────────┘ filtered└──────────────────┘ DLP'd └─────────────────┘
│
policy.yml │ audit.jsonl (HMAC)
▼
PolicyEngine + security modules
Role: Discover MCP server configurations across 8 client families on the local machine and attach a risk assessment to each discovered server.
Files:
| File | Export | What it scans |
|---|---|---|
cursor.ts |
detectCursor() |
~/.cursor/mcp.json, ./.cursor/mcp.json, Cline extension settings (cline_mcp_settings.json) |
claude-desktop.ts |
detectClaudeDesktop() |
claude_desktop_config.json (platform-aware: macOS ~/Library/Application Support/Claude/, Windows %APPDATA%/Claude/, Linux ~/.config/Claude/) |
vscode.ts |
detectVSCode() |
~/.vscode/mcp.json, ~/.vscode/mcp-servers.json, workspace-local variants, plus every installed github.copilot-chat-* extension's mcp.json (client name vscode-copilot) |
chatgpt-desktop.ts |
detectChatGPTDesktop() |
~/Library/Application Support/com.openai.chatgpt/mcp.json |
codex.ts |
detectCodex() |
~/.codex/mcp.json, ~/.config/codex/mcp.json, ./.codex/mcp.json |
jetbrains.ts |
detectJetBrains() |
Per-product mcp.json under the JetBrains config dir for 6 IDEs (IntelliJIdea, WebStorm, PyCharm, GoLand, Rider, PhpStorm) + shared ~/.config/JetBrains/mcp-servers.json |
index.ts |
detectAll(), parseMcpServers(), detectByClient() |
Orchestrator — also covers windsurf (~/.codeium/windsurf/mcp.json) and project-local (./.mcp/config.json, ./.mcp.json, ./mcp.json) |
risk.ts |
assessRisk() |
13 heuristic risk rules → RiskAssessment |
How it fits: detectAll() runs all six file-based detectors concurrently with Promise.allSettled (one failing detector never sinks the scan), appends the windsurf/project locations, then deduplicates servers across clients by the key command|args|name. Every client-specific parser shares the same shape: read JSON → accept either a top-level mcpServers object or a bare server map → build McpServerConfig (normalizing command/cmd, defaulting transport: "stdio") → run assessRisk(server).
Risk engine (risk.ts): Each of the 13 RISK_RULES is a {check, rule, description, severity} tuple evaluated against the server config — e.g. shell-interpreter (command is bash/sh/python/…, critical), docker-container (privileged docker flags, high), no-sandbox, network-tool, network-transport, process-spawn, destructive-fs, remote-access, sensitive-env, package-runner (npx/uvx/pipx), risky-package, privilege-escalation, sensitive-paths. Scores are summed (SEVERITY_SCORES: critical 40, high 25, medium 10, low 5) and bucketed into levels: >=60 critical, >=30 high, >=10 medium, else low. Each flag is cross-tagged to the OWASP LLM Top 10 via mapRiskToOWASP() in src/owasp-mapping.ts (e.g. shell-interpreter → LLM06 Excessive Agency).
Data flow:
config file (JSON) ──► parseConfigFile()/parseMcpServers() ──► McpServerConfig
│ assessRisk()
▼
McpClientConfig { client, path, servers[] } ◄── RiskAssessment { score, level, flags[] }
Consumed by: init (policy generation), check, report, proxy (server registration).
Role: The decision core. Evaluates every tool call against an ordered rule list and returns one of four actions: allow, deny, warn, redact.
Key files:
engine.ts—class PolicyEngineevaluate(toolName, toolDescription, args, context?) → EvaluateResult— the synchronous hot path. Order of operations:- Redact pass — all
action: "redact"rules collect arg keys viagetRedactKeys()(key-name matching only). - Allowlist short-circuit —
allowlist.tools.includes(toolName)→ immediateallow. - Audit mode — everything allowed (still records audit entries and feeds the baseline); action becomes
redactif keys matched. - Enforce mode — start from
defaultAction, iterate non-redact rules: skip rules outside theirtimeWindow(isWithinTimeWindow, day names + hour range with overnight wrap), match viaruleMatches(), honorcontextCondition(clientIn,maxRequestsPerMinutesliding window inrequestTimestamps), enforceargConstraints(checkArgConstraints— equals / startsWith / regex / in / notIn; a failed constraint on a matching rule forcesdeny). Firstdenywins and breaks;warnandallowsettle by precedence (deny > warn > allow). Any redacted keys escalate a non-deny result toredact. - Behavioral baseline —
baseliner.detectDeviation()appends[baseline]reasons, thenbaseliner.observe()updates the profile.
- Redact pass — all
evaluateWithJudge(...)— async wrapper: runsevaluate(), thencheckThreatIntel(args)(allow → warn on malicious IOC), then the optionalLLMJudgewithescalate()(never de-escalates adeny).getEffectiveTimeout(...)— first matching rule withtimeoutMswins; falls back todefaultTimeoutMs(30 s).loadFromFile()/resolveAndLoad()— YAML loading withextendssupport: recursive base-file resolution, circular-extends detection via avisitedset, andmergeConfigs()(base rules first, current file overrides by ruleid; allowlists unioned).generateAllowlistFromAudit(since)/generateSuggestedPolicy()— turns observed audit history into a draft enforce-mode policy.- Rule CRUD:
addRule,removeRule,updateRule(id-keyed).
engine.ts—class BehavioralBaseline- Per-tool
ToolProfile:totalCalls, 24-buckethourDistribution,typicalArgs(key → count + up to 10 sample string values <100 chars), runningavgArgSize. detectDeviation()emitsDeviations once a tool passesBASELINE_WINDOW = 100calls:new_args(unseen arg keys, warn),size_anomaly(args >3× average, warn),hour_anomaly(hour with zero history, or outside 2σ of the hourly mean, info),new_tool(first sighting, info).
- Per-tool
defaults.ts—DEFAULT_POLICY(enforce mode, default-deny, 9 rules with compliance mappings:block-shell-execution,block-sensitive-paths,block-credential-access,redact-credentials,block-private-network,block-process-execution, business-hours and/workspaceallow rules,block-credential-leakage),DEFAULT_TEMPLATES(minimal-workstation,pci-compliance,strict-production),generateDefaultPolicy(configs, mode)(seedsallowlist.toolswith every detected server name andallowlist.hostswith detected URL hosts),generateDefaultPolicyFile()(commented YAML).schema.ts—validatePolicy(config) → PolicyConfig: strict structural validation. Enforces enum domains (target: command/file/network/env/process;match: exact/pattern/contains;action: allow/deny/warn/redact;mode: audit/enforce),timeoutMsbounds (100 ms – 300 000 ms),timeWindowday/hour validity,argConstraintsshape,contextConditionshape, webhooknotifications(slack/discord/json formats), and per-rulecompliancearrays across 6 frameworks (soc2,hipaa,gdpr,pci-dss,iso27001,nist).yaml.ts— thin js-yaml wrapper:parse,stringify,parsePolicy(= parse + validate).llm-judge.ts—class LLMJudge: semantic second opinion. Default is a zero-dependency heuristic evaluator (heuristicEvaluate) scanning flattened arg values for base64 blobs, command-injection metacharacters, path traversal, exfiltration endpoints (Discord/Slack/Telegram webhooks, ngrok…), sensitive arg keys, and suspicious description keywords. With--judge-keyit calls OpenAI (gpt-4o-minidefault) or Anthropic (claude-3-haikudefault) with a 5 sAbortControllertimeout and falls back to heuristics on any API error. Results cached in a 100-entry LRU with 5-minute TTL.escalate(action, judgeResult): suspicious + ≥3 risk factors stepsallow→warn→deny; suspicious alone stepsallow→warn; never loosens.
Data flow:
PolicyRule[] ─► PolicyEngine.evaluate(tool, description, args, ctx)
│
▼
EvaluateResult { action: allow|deny|warn|redact,
reasons: ["[rule-id] …", "[baseline] …"],
redactedKeys?: string[] }
Role: The runtime interception point. An Express HTTP server (default port 9420) that accepts JSON-RPC POSTs per registered server name and brokers them to upstream MCP servers over four transports.
server.ts — class ProxyServer
- Registration:
register(server, client)(recordsRegisteredServer { name, originalUrl, proxyUrl, risk }) +registerServer(config);start()instantiates one transport client per server, wiresonNotification(anotifications/tools/list_changedfrom a stdio/SSE upstream triggers a tool-description cache refresh), pre-caches tool descriptions viacacheToolDescriptions()(atools/listcall at startup soPolicyEngine.evaluatehas descriptions forcommand-target rules), then listens. - Resilience: per-server circuit breaker (
checkCircuit/recordSuccess/recordFailure; opens afterCIRCUIT_THRESHOLD = 5consecutive failures forCIRCUIT_TIMEOUT = 30 s, then half-open probe), per-IP rate limiter (checkRateLimit, default 100 req/min, 60 s window, entries swept every minute bycleanupRateLimits), optional Bearer API key (options.apiKey→ 401 on mismatch). - Observability:
getStats()(ProxyStatsincl. per-transport timeout counts),getLatencyStats()(nanosecondprocess.hrtimesampling into a 10 000-entry ring → p50/p95/p99/avg/max in ms + throughput over a 5 s sliding window);recordRequestTiminghooked onres.on('finish'). - Hot reload:
reloadPolicy(newConfig)swaps in a brand-newPolicyEngine(returns rule count). - Routes:
GET /health— status + stats + latency.GET /servers— registered servers and proxy URLs.POST /:serverName— the 12-stage pipeline (§3).GET /:serverName— registration info.POST /:serverName/explain— dry-run policy evaluation with a per-rule matched/not-matched trace (powers the dashboard's Simulate button).
- Options (
ProxyServerOptions):apiKey,rateLimit,dlp(default true),defaultTimeoutMs,injectHoneytokens(defaults to true in audit mode, false in enforce),forensicsCapture.
intercept.ts — request filtering layer (pure functions over MCPRequest/MCPResponse):
interceptRequest(request, policy, serverName, context?, toolDescription?) → MCPResponse | null— returns a blocking response ornullto proceed. Handles:initialize(always pass),notifications/*(handleNotification— logging;notifications/sampling/createMessagecarrying user data is blocked as an exfiltration risk),tools/call(evaluate;deny→ JSON-RPC error-32001with reasons inerror.data;redact→ deletes the offending keys fromparams.argumentsin place before forwarding;warn→ log only),resources/read,resources/subscribe,prompts/get(same evaluate gate on the URI/name),completion/complete(allowed, logged),sampling/createMessage(gated bypolicy.isSamplingAllowed()).filterToolsListResponse/filterResourcesListResponse/filterPromptsListResponse— post-response filters that remove denied entries from discovery lists, so a blocked tool is invisible to the agent.scanResponse(response, policy) → ScanResult— the DLP scanner:deepScanStringswalks every string in the response and rewrites matches of 6SECRET_PATTERNS(aws-access-keyAKIA…,github-tokenghp_…,openai-keysk-…, PEM private keys,api-key/bearerassignments, genericsecret|password|token|key|credentialliterals) to[REDACTED-<type>], returning{ response, redactedCount, redactions[] }.- Both
interceptRequestdeny/warn/redact paths firenotifications.ts—notifyPolicyEvent(), which POSTs to each configured webhook whoseeventslist includes the action, formatted forslack,discord, or rawjson.
Data flow:
MCPRequest ─► interceptRequest ─► PolicyEngine.evaluate ─► (block | mutate | pass)
pass ─► transport.send(request, effectiveTimeout) ─► upstream MCPResponse
MCPResponse ─► scanResponse (DLP) ─► injectHoneytokens ─► list filters ─► client
attack-chains.ts — multi-step attack detection (XState).
An XState v5 state machine (attackChainMachine) models the kill chain as states idle → reconnaissance | execution → persistence → exfiltration_attempt → exfiltration_confirmed (final), with a 5-minute after timeout decaying every non-final state back to idle. classifyEvent() maps each CallEvent to a machine event using tool-name/arg heuristics — READ_SENSITIVE (read of /etc, passwd, shadow), SHELL_EXEC, WRITE_SSH (.ssh/authorized_keys), NETWORK_CALL (http/fetch/curl-like tools), LARGE_FILE_READ (read with size > 1 000 000), WRITE_SYSTEM, EXEC_PROCESS. trackCall(call) keeps one running actor per sessionId in a module-level Map and returns { alert, state }; alert: true (state exfiltration_confirmed) makes the proxy block the call outright. cleanupSession() / getSessionCount() manage the registry.
honeytokens.ts — decoy credential tripwires.
injectHoneytokens(response, { serverName, sessionId, types? }) appends generated decoys — AWS key pairs, ghp_ GitHub tokens, Postgres connection URLs to *.internal honeypot hosts — to every response.result.content[].text item, and records each in a plantedTokens map keyed by token value. detectHoneytokenAccess(args, serverName) substring-scans all string args of subsequent calls; a hit marks the token detected, appends to detectionLog, and the proxy answers 403 with an alert. Introspection: getPlantedCount, getDetectedCount, getDetectionLog, clearHoneytokens.
forensics.ts — session capture (.mcpcap.json).
Module-singleton SessionCapture: startSessionCapture() → UUID session; captureRequest/captureResponse append { timestamp, direction, payload } events; saveSession() writes <sessionDir>/<sessionId>.mcpcap.json (default .mcp-seatbelt/sessions/) with eventCount and clears the active session. Wired into the proxy by the forensicsCapture option and the record CLI command (which also installs SIGINT/SIGTERM handlers to flush the capture on exit).
fuzzer.ts — policy bypass hunting.
fuzzTool(toolName, toolSchema, policy, { iterations }) uses json-schema-faker to synthesize schema-conformant payloads, then overlays 8 hard-coded EDGE_CASES (../../etc/passwd, rm -rf /, http://evil.com/steal?data=, /etc/shadow, 127.0.0.1, null-byte injection, SQLi '; DROP TABLE users; --, JNDI ${jndi:ldap://…}) onto any missing keys and runs policy.evaluate. Every edge case that survives un-denied is reported as a bypass. fuzzServer maps it over a tool list.
schema-validator.ts — input validation.
compileToolSchema(toolName, schema) pre-compiles JSON Schemas with Ajv (allErrors, non-strict) into a validator cache. validateToolArgs(toolName, args) returns { valid, errors[] } (permissive pass-through when no schema is cached). validatePathSafety(args) is a schema-independent sweep flagging ../ / ..\ traversal, absolute sensitive prefixes (/etc/, /root/, C:\Windows), and null-byte injection.
security/index.ts re-exports the module surface; src/index.ts re-exports it again for library consumers.
Per-agent authorization layered on top of the rule engine, built on casbin. initRBAC(modelPath?, policyPath?) constructs a casbin Enforcer from .mcp-seatbelt/rbac_model.conf + .mcp-seatbelt/rbac_policy.csv (generated by the rbac-init command: an ACL model m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act with roles admin/viewer and groupings agent_admin/agent_viewer). At request time the proxy reads the agent identity from the x-agent-id header (default unknown_agent) and calls checkAccess(agentId, toolName, "execute"). Fail-open by design: when RBAC is uninitialized (enforcer === null), checkAccess returns true, so RBAC is strictly opt-in. getEnforcer() and resetRBAC() support testing.
IOC reputation checks against the ThreatFox API (https://threatfox.abuse.ch/api/v1/, search_ioc query). checkThreatIntel(args) extracts string arg values that look like IPv4 addresses or domains (email-like and leading-dot values excluded) and queries each with a 3 s AbortController timeout; results are cached for 1 hour (CACHE_TTL) in a module-level map. Any malicious: true result hard-blocks the call at proxy stage 11 (403 with the indicator list in error.data), and escalates allow → warn inside evaluateWithJudge. All network failures return null — threat intel never breaks the proxy. clearCache() exported for tests.
All commands are lazily await import()-ed from src/index.ts (commander), keeping CLI startup fast. The binary exposes 15 commands:
| Command | File | Purpose |
|---|---|---|
init |
init.ts |
detectAll() → risk table → write policy.yml + risk-report.md → optionally rewrite Cursor mcp.json entries to proxy URLs (with .backup) or print proxy URLs for other clients. --policy audit|enforce, --yes |
proxy |
proxy.ts |
Load + validate policy, optional LLMJudge/AuditTrail wiring, detectAll() → ProxyServer.register() each server → start(). Hot reload via fs.watch + SIGHUP/SIGUSR1 (500 ms reload gate), --stats prints 5 s heartbeat, graceful SIGINT/SIGTERM shutdown |
report |
report.ts |
Markdown (default), JSON (RiskReport), or SARIF 2.1.0 (--sarif) risk reports from report/generator.ts + report/sarif.ts |
check |
check.ts |
Zero-write risk summary; exit code 1 when any critical flag/server exists (CI gate) |
diff |
diff.ts |
Structural diff of two policy YAMLs (added/removed/modified rules by id) or two JSON risk reports (server set + risk-score deltas) |
dashboard |
dashboard.ts |
Dependency-free node:http UI on :9421 — stat cards, block-rate bar, recent blocked calls with OWASP/compliance badges, /api/stats, /api/events (SSE), and a /<server>/explain simulate endpoint backed by its own PolicyEngine when --policy is given. addBlockedCall() is dynamically imported by the proxy to feed the UI |
import-observatory |
import-observatory.ts |
Converts mcp-observatory scan artifacts into suggested PolicyRules (severity→action: critical/high→deny, medium→warn, low→allow) via integrations/observatory.ts; auto-discovery of .mcp-observatory/runs/ and .mcp-observatory-metrics/ |
baseline |
baseline.ts |
Replays a JSON audit log through BehavioralBaseline and prints profiles + anomaly summary |
verify-audit |
(inline in index.ts, uses audit.ts) |
Recomputes per-entry HMAC-SHA256 over a signed .audit.jsonl; exits 1 on tamper |
record |
(inline in index.ts, uses security/forensics.ts) |
Starts the proxy in forensic capture mode; SIGINT/SIGTERM flushes a .mcpcap.json session |
benchmark |
benchmark.ts |
Concurrency-batched tools/call load against a running proxy: warmup, p50/p95/p99, req/s, and a proxy-side processed-vs-blocked reconciliation via /health |
simulate |
simulate.ts |
Offline single-call evaluation with a full rule trace (matched / applied / time-window status per rule), --json and --verbose |
test-policy |
test-policy.ts |
YAML-driven policy unit tests (tests: [{name, tool, args, expect, matchReason}]) against a policy file or DEFAULT_POLICY; exit 1 on failure |
fuzz |
fuzz.ts |
Runs the security fuzzer across 21 common tool names, reporting blocked/allowed/bypass counts per tool |
rbac-init |
rbac-init.ts |
Writes the default casbin model + policy CSV into .mcp-seatbelt/ |
Note: there is no separate
enforcecommand — enforcement is a policy property (mode: enforceinpolicy.yml, chosen atinit --policy enforce) that the proxy honors.
src/types.ts— every shared contract:McpClientConfig,McpServerConfig,RiskAssessment/RiskFlag,PolicyRule(withArgConstraint,timeWindow,contextCondition,timeoutMs,compliance),PolicyConfig(incl.extends,notifications.webhooks),RiskReport/ServerReport/ToolReport,ProxyStats,ProxyServerOptions.src/audit.ts—class AuditTrail: tamper-evident JSONL logging. Each entry gets_seq+_hmac= HMAC-SHA256(secret,seq + JSON.stringify(entry)); appends are serialized through a promise-chainwriteLock.verify()streams the file line-by-line and recomputes;query()filters by since/tool/action.verifyAuditFile()backs the CLI.src/owasp-mapping.ts—OWASP_LLM_MAPPING(risk-rule → OWASP LLM Top 10 id), the fullOWASP_LLM_TAXONOMY_ENTRIESandCOMPLIANCE_TAXONOMY_ENTRIESused by SARIF output and dashboard badges.src/integrations/observatory.ts— mcp-observatory artifact reader (readObservatoryJsontolerating bothchecks[].evidence[].findings[]and flatfindings[]shapes), heuristicfindingToTarget/findingToValuesmapping,importObservatoryResults,mergeObservatoryPolicy,discoverObservatoryArtifacts.
Every POST /:serverName to the proxy traverses the following stages in this exact order (function/class names as they appear in src/proxy/server.ts and its collaborators). Any stage may terminate the request with a JSON-RPC error; only survivors reach the upstream server. (The README markets an "11-stage" grouping that folds authentication and rate limiting together; the code path below is the authoritative execution order.)
MCPRequest (JSON-RPC 2.0)
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ STAGE 1 AUTHENTICATION │
│ ProxyServer options.apiKey ⇔ Authorization: Bearer <key> │
│ fail ─► 401 { code: -32003 "Unauthorized" } │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 2 RATE LIMITING │
│ checkRateLimit(clientIp) [rateLimits map, 100 req/min sliding window] │
│ fail ─► 429 { code: -32004 "Rate limit exceeded" } │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 3 FORENSIC CAPTURE │
│ captureRequest(mcpRequest) [forensics.ts, if forensicsCaptureFlag] │
│ (observe-only, never blocks) │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 4 HONEYTOKEN TRIPWIRE │
│ detectHoneytokenAccess(params.arguments, serverName) │
│ hit ─► 403 { code: -32001 "Honeytoken detected…" } + stderr ALERT │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 5 PROTOCOL & ROUTE VALIDATION │
│ jsonrpc === "2.0" ? servers.has(serverName) ? │
│ fail ─► 400 { -32600 } / 404 { -32601 "Unknown server" } │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 6 CIRCUIT BREAKER │
│ checkCircuit(serverName) [5 failures ⇒ open 30 s ⇒ half-open] │
│ open ─► 503 { code: -32002 "Circuit open" } │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 7 POLICY INTERCEPTION │
│ interceptRequest(req, policy, serverName, ctx, toolDescription) │
│ └─► PolicyEngine.evaluate(tool, description, args, ctx) │
│ redact ⇒ delete params.arguments[key] in place (continue) │
│ warn ⇒ console.warn + notifyPolicyEvent() (continue) │
│ deny ─► 200 { code: -32001 "Blocked by MCP Seatbelt" } │
│ + addBlockedCall() → dashboard, stats.blocked++ │
│ also gates: resources/read, resources/subscribe, prompts/get, │
│ sampling/createMessage (allowSampling), notifications/* │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 8 ATTACK-CHAIN TRACKING │
│ trackCall({ toolName, args, sessionId, timestamp }) [XState machine] │
│ state ⇒ exfiltration_confirmed ─► 400 "attack chain detected" │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 9 SCHEMA & PATH VALIDATION │
│ validateToolArgs(tool, args) [ajv-compiled inputSchema cache] │
│ invalid ─► 400 { -32602 "Schema validation failed" } │
│ validatePathSafety(args) [../ traversal, /etc/, /root/, null bytes] │
│ unsafe ─► 400 { -32001 "Path safety violation" } │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 10 RBAC AUTHORIZATION │
│ checkAccess(x-agent-id header, toolName, "execute") [casbin Enforcer] │
│ denied ─► 403 { -32001 "RBAC denied" } (fail-open when uninitialized) │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 11 THREAT INTEL │
│ checkThreatIntel(params.arguments) [ThreatFox IOC lookup, 1 h cache] │
│ malicious ─► 403 { -32001, data.indicators[] } │
├──────────────────────────────────────────────────────────────────────────┤
│ STAGE 12 UPSTREAM DISPATCH + RESPONSE PIPELINE │
│ timeout = policy.getEffectiveTimeout(tool, desc, args, defaultTimeoutMs) │
│ client = clients.get(serverName) [Stdio|Http|Sse|StreamableHttp] │
│ │
│ upstreamResponse = await client.send(mcpRequest, timeout) ──────────┐ │
│ ok: recordSuccess(serverName); err: recordFailure + 502/504 │ │
└───────────────────────────────────────────────────────────────────────┼──┘
│
▼
┌───────────────────────────── RESPONSE PATH ────────────────────────────┐
│ R1 captureResponse(response) [forensics, observe-only] │
│ R2 scanResponse(response, policy) [DLP: 6 SECRET_PATTERNS → │
│ [REDACTED-type]; stats.redactedCount += n] │
│ R3 injectHoneytokens(response, …) [plant decoy credentials] │
│ R4 method-aware filtering: │
│ tools/list ⇒ filterToolsListResponse (drop denied tools) │
│ resources/list ⇒ filterResourcesListResponse │
│ prompts/list ⇒ filterPromptsListResponse │
│ tools/call ⇒ post-hoc policy.evaluate for warn/redact stats│
│ R5 res.json(finalResponse); recordRequestTiming(hrtime) │
└────────────────────────────────────────────────────────────────────────┘
End-to-end trace of a blocked call:
Agent ──POST /fs {tools/call read_file {path:"/etc/shadow"}}──► ProxyServer
1 auth ok → 2 rate ok → 3 captured → 4 no honeytoken → 5 valid → 6 closed
7 interceptRequest → PolicyEngine.evaluate("read_file", …, {path:"/etc/shadow"})
rule [block-sensitive-paths] target:file pattern ^/etc(/|$) ⇒ MATCH ⇒ deny
──► 200 { error: -32001, data.reasons:[…] } ──► Agent (upstream never called)
──► notifyPolicyEvent() → Slack/Discord webhook; addBlockedCall() → dashboard
The proxy normalizes all four MCP transports behind one structural interface: start(), stop(), send(request: MCPRequest, timeoutMs?) → Promise<MCPResponse | null>, a timedOut counter, and (for streaming transports) an onNotification callback. The union type is McpClient = StdioClient | HttpClient | SseClient | StreamableHttpClient; selection happens in ProxyServer.start() from ServerRegistration.transport (+url).
ProxyServer.start()
│ transport ──┬── "stdio" ─► StdioClient(command, args, env)
│ ├── "http" + url ─► HttpClient(url)
│ ├── "sse" + url ─► SseClient(url)
│ └── "streamable-http" ─► StreamableHttpClient(url)
▼
await client.start() → cacheToolDescriptions(name, client) → listen(:9420)
| Transport | Class | Mechanism | Failure handling |
|---|---|---|---|
| stdio | StdioClient |
child_process.spawn with piped stdio; newline-delimited JSON-RPC framed via readline on stdout, with a best-effort JSON parser on stderr too (some servers emit responses there; non-JSON stderr is mirrored to the proxy's stderr). Notifications (no id) route to onNotification. |
Auto-restart up to MAX_RESTARTS = 5 with 1 s backoff; on request timeout the child is SIGTERM'd, SIGKILL'd after 2 s, and all pending requests are rejected. Requests without id are fire-and-forget (resolve null). |
| http | HttpClient |
Plain fetch POST of the JSON-RPC body, expecting a JSON response. |
AbortController timeout (default 30 s); stop() aborts every in-flight controller. |
| sse | SseClient |
Legacy MCP SSE: a long-lived GET with Accept: text/event-stream delivers responses/notifications (data: frames accumulated per blank-line boundary); requests are POSTed to the sibling /message endpoint derived from the stream URL. Responses are correlated to POSTs by id through the pending map. |
Auto-reconnect up to MAX_RECONNECTS = 5 (1 s delay); per-request timeout rejects the pending promise; stop() aborts the stream and rejects all pending. |
| streamable-http | StreamableHttpClient |
Current MCP spec: single endpoint, POST with Accept: application/json, text/event-stream; the response may be plain JSON or an SSE body, in which case data: lines are concatenated and parsed. |
Same AbortController timeout pattern; stateless beyond stopped. |
Common semantics: per-request timeouts default to 30 s but are overridden per call by PolicyEngine.getEffectiveTimeout() (stage 12), so a rule like block-shell-execution can carry timeoutMs: 10000. Every transport increments its timedOut counter, which getStats() aggregates into ProxyStats.timedOut.
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. INIT (detection) mcp-seatbelt init [--policy audit] │
│ detectAll() ─► assessRisk() per server │
│ generateDefaultPolicy(configs, mode) │
│ · rules ← DEFAULT_POLICY.rules (9 built-ins, compliance-tagged) │
│ · allowlist.tools ← every detected server name │
│ · allowlist.hosts ← hostnames of detected server URLs │
│ write .mcp-seatbelt/policy.yml + .mcp-seatbelt/risk-report.md │
├─────────────────────────────────────────────────────────────────────────┤
│ 2. STORAGE .mcp-seatbelt/policy.yml (source of truth)│
│ version / mode / defaultAction / defaultTimeoutMs │
│ rules[] (id, description, target, match, values[], action, │
│ argConstraints?, timeWindow?, contextCondition?, │
│ timeoutMs?, compliance?) │
│ allowlist { tools, paths, hosts, envVars } │
│ allowSampling · extends[] · notifications.webhooks[] │
├─────────────────────────────────────────────────────────────────────────┤
│ 3. ENFORCEMENT mcp-seatbelt proxy │
│ readFileSync ─► parsePolicy() = js-yaml load + validatePolicy() │
│ ─► new PolicyEngine(config) │
│ ─► optional: setJudge(LLMJudge), setAuditTrail(AuditTrail) │
│ ─► ProxyServer(policy, port, options).register(...).start() │
├─────────────────────────────────────────────────────────────────────────┤
│ 4. HOT RELOAD (--watch, default on, CI off)│
│ fs.watch(configPath, "change") ─┐ │
│ SIGHUP / SIGUSR1 ───────────────┼─► reloadPolicy() [500 ms gate] │
│ re-read ─► parsePolicy ─► proxy.reloadPolicy(newConfig) │
│ = swap in a fresh PolicyEngine (rule count logged) │
│ invalid YAML/validation error ⇒ logged, old engine stays live │
└─────────────────────────────────────────────────────────────────────────┘
Auxiliary lifecycle tools: simulate/test-policy/fuzz validate changes before reload; diff compares two policy files; baseline + PolicyEngine.generateSuggestedPolicy() turn audit-mode observations into a candidate enforce-mode policy; extends lets teams layer a local override file on top of a shared base policy (mergeConfigs: base rules first, same-id overrides win, allowlists union).
491 tests across 19 files (tests/*.test.ts), run with Vitest (vitest run; globals: true, no DOM environment needed). Test distribution:
| File | Tests | Scope |
|---|---|---|
policy.test.ts |
93 | PolicyEngine unit coverage: every target/match/action combination, time windows, context conditions, arg constraints, redaction, allowlists, audit vs enforce modes, extends/merge, allowlist generation, suggested policy |
proxy.test.ts |
65 | interceptRequest, list filters, scanResponse DLP patterns, sampling gating, notifications handling |
detectors.test.ts |
52 | All 8 client detectors against fixture configs + all 13 risk rules |
proxy-server.test.ts |
47 | ProxyServer in-process: transports, circuit breaker, rate limiting, auth, stats, latency, explain endpoint |
cli.test.ts |
31 | End-to-end CLI invocations (spawns the CLI via tsx) |
llm-judge.test.ts |
27 | Heuristic evaluator, escalation matrix, cache behavior, API-mode mocks |
baseline.test.ts |
21 | BehavioralBaseline profiles, deviations, report rendering |
audit.test.ts |
20 | HMAC signing, tamper detection, query filters, write-lock ordering |
schema-validator.test.ts |
19 | ajv compilation, arg validation, path-safety violations |
honeytokens.test.ts |
17 | Injection, detection, counters, clearing |
forensics.test.ts |
14 | Session lifecycle, .mcpcap.json round-trip |
report.test.ts |
13 | Markdown/JSON/SARIF generators |
threat-intel.test.ts |
13 | IOC extraction, caching, fetch mocks, fail-open behavior |
attack-chains.test.ts |
12 | State-machine transitions, timeouts, session cleanup |
rbac.test.ts |
12 | casbin enforcer, fail-open default, model/policy fixtures |
notifications.test.ts |
12 | Webhook formats (slack/discord/json), event filtering |
schema-notifications.test.ts |
11 | validatePolicy notification + compliance validation |
integration-proxy.test.ts |
6 | Integration: real proxy CLI + real @modelcontextprotocol/server-filesystem & server-memory over HTTP; attack-chain test pins Date.now() because the proxy derives sessionId from serverName + "_" + Date.now(); describe.skipIf guards when those packages aren't installed |
integration.test.ts |
6 | Integration: mcp-observatory → seatbelt policy import; requires mcp-observatory binary / npx, skipped otherwise |
Unit vs integration: 479 tests are hermetic unit/CLI tests (mocked filesystem via temp dirs, mocked fetch, in-process engines). The 12 integration tests spawn real processes — the proxy CLI with an isolated $HOME containing a fixture .cursor/mcp.json, real MCP server packages, and real HTTP round-trips — and self-skip when their external dependencies are absent.
Running subsets:
npm test # full suite (vitest run)
npx vitest run tests/policy.test.ts # one file
npx vitest run tests/proxy.test.ts tests/proxy-server.test.ts # several files
npx vitest run -t "redact" # name filter across all files
npx vitest run tests/integration-proxy.test.ts # integration (needs dev deps installed)
npm run typecheck && npm run lint # static gates- Create
src/detectors/<client>.tsexportingdetect<Client>(): Promise<McpClientConfig[]>. Follow the establishedparseConfigFile(path, client)pattern (exists-check → JSON parse → acceptmcpServerswrapper or bare map → normalizecommand/cmd,args,env,transport,url→assessRisk(server)per server →nullwhen empty or unparseable). - Register it in
src/detectors/index.ts: add the import and one entry in thePromise.allSettled([...])list insidedetectAll()— failures are isolated automatically. For single-path conventions you may instead append toextraLocations(the windsurf/project pattern). - Add fixture-based tests in
tests/detectors.test.ts.
Rules are data, so most additions need no code: append to policy.yml (or to DEFAULT_POLICY.rules / DEFAULT_TEMPLATES in src/policy/defaults.ts to ship them built-in). A rule needs a unique id, a target (command|file|network|env|process), a match strategy (exact|pattern|contains), values, and an action; optionally argConstraints, timeWindow, contextCondition, timeoutMs, and compliance mappings. To extend static risk detection instead, add a {check, rule, description, severity} entry to RISK_RULES in src/detectors/risk.ts and, optionally, map it to the OWASP LLM Top 10 in src/owasp-mapping.ts. Validate behavior with simulate, then lock it in with a test-policy YAML case.
- Implement
src/security/<module>.tswith pure, side-effect-scoped functions (seehoneytokens.ts/forensics.tsfor the module-singleton pattern). - Export it from
src/security/index.tsand re-export fromsrc/index.tsfor library consumers. - Hook it into the pipeline in
ProxyServer.setupRoutes()(src/proxy/server.ts) at the appropriate stage — inbound stages run beforeclient.send, response stages after. Gate it behind aProxyServerOptionsflag if it mutates traffic, and surface counters throughProxyStats. - Add a focused
tests/<module>.test.tsplus a proxy-level test if it touches the request path.
- Add a class in
src/proxy/server.ts(or a new file) implementing the transport contract:start(),stop(),send(request, timeoutMs?) → Promise<MCPResponse | null>, a publictimedOutcounter, and — if the transport is streaming/notification-capable — anonNotificationproperty (the proxy wires it to refreshcacheToolDescriptionsonnotifications/tools/list_changed). Correlate responses by JSON-RPCidwith the pending-map + per-request timeout pattern used byStdioClient/SseClient. - Extend the
McpClientunion, thetransportliteral insrc/types.ts(McpServerConfig.transport) andServerRegistration, and the selection chain inProxyServer.start(). - Cover it in
tests/proxy-server.test.ts(construction, send/timeout, stop semantics).
- Create
src/commands/<name>.tsexporting<name>Command(opts); usechalkfor output andprocess.exit(1)for CI-friendly failures, matching existing commands. - Register in
src/index.tswithprogram.command("<name>"),.description(),.option()s, and a lazyawait import("./commands/<name>.js")action. - If the command is library-worthy, add its exports to the re-export block at the bottom of
src/index.ts. - Add invocation tests to
tests/cli.test.ts.
Document generated from a full read of src/ (45 files, ~9,300 LOC) at v0.4.1. Where behavior is order-sensitive (the 12-stage pipeline), stage numbering follows the literal execution order in ProxyServer.setupRoutes().