nullwire-mcp
by VoxNode-ux
README.md
<div align="center">
# ⚡ NullWire-MCP
### Your AI agent can debug live HTTP traffic.<br>It never sees a single secret.
Every token, cookie and IP is swapped for a **stable, salted tag**, so the agent can still tell<br>
*"same token reused"* from *"token rotated"*: the question behind most auth bugs.
[](https://github.com/VoxNode-ux/nullwire-mcp/actions/workflows/release.yml)
[](https://github.com/VoxNode-ux/nullwire-mcp/releases)

-brightgreen)
-brightgreen)

[](LICENSE)
[**Quick start**](#-quick-start-in-60-seconds) ·
[**How it works**](#-how-it-works) ·
[**Install**](#-30-second-install) ·
[**Tools**](#-mcp-tool-reference) ·
[**Security**](#-security--protocol-engineering-deep-dive) ·
[**Limitations**](#-honest-scope--known-limitations) ·
[**FAQ**](#-faq)
</div>
<!-- Tip: record a 10-15s terminal GIF (curl with a secret, then the agent's view) and place it here:
<p align="center"><img src="docs/demo.gif" width="760" alt="NullWire-MCP demo"></p> -->
---
## 👀 See the difference
```text
You send Authorization: Bearer sk-live-51Hk3...
Your AI sees Authorization: Bearer [REDACTED_TOKEN#a1b2c3]
Request #1 Bearer [REDACTED_TOKEN#a1b2c3]
Request #2 Bearer [REDACTED_TOKEN#a1b2c3] ◄── same token, reused ✔
Request #3 Bearer [REDACTED_TOKEN#7f09de] ◄── different token, it rotated ✔
```
Blind `[REDACTED]` protects you but makes the AI useless for auth bugs. NullWire keeps the *identity* of each secret and throws away the *value*.
| 📦 **0** dependencies | 💾 **0** bytes written to disk | 🔍 **1** source file to audit | 🖥️ **9** release targets |
|:---:|:---:|:---:|:---:|
| pure Go stdlib | RAM-only 50-flow ring | `main.go` | Linux · macOS · Windows · FreeBSD |
---
## ⚡ Quick start in 60 seconds
```bash
# 1. install (Go 1.23+), or grab a prebuilt binary from Releases
go install github.com/VoxNode-ux/nullwire-mcp@latest
# 2. register it with your AI client (Claude Code shown; Cursor/Windsurf/Desktop configs below)
claude mcp add nullwire -- nullwire-mcp -port 8080 -target https://api.example.com
# 3. point your app at the proxy instead of the real API
curl http://127.0.0.1:8080/v1/orders -H "Authorization: Bearer $MY_TOKEN"
```
Then just ask your agent:
> *"Why is `/v1/orders` returning 401? Compare the auth headers of the last 5 requests."*
> *"Did request 3 and 4 use the same token, or did it rotate?"*
> *"Give me a curl to replay the failing call."*
Your app always gets the **real, untouched response**. Only the agent's view is scrubbed.
---
## 🔥 Why this exists
Point an AI agent at your network traffic and a typical capture proxy hands it **everything**: live bearer tokens, session cookies, internal IPs. All of it lands in a cloud LLM context window, and often in a capture file left on disk.
Blind `[REDACTED]` stops the leak but kills the debugging. NullWire-MCP redacts every secret **in RAM** and swaps it for a **deterministic, per-process salted tag**: the agent keeps the *identity* of each secret and never sees the *value*.
> The tag is `SHA-256(nonce ‖ secret)[:6]`. The `nonce` is 32 random bytes drawn from `crypto/rand` at startup, kept **only in RAM**, and gone when the process exits. See the [security deep-dive](#-security--protocol-engineering-deep-dive) for why that matters.
---
## 🧭 How it works
```mermaid
flowchart LR
APP["Your app / curl / SDK"] -->|"plain HTTP to 127.0.0.1"| PROXY["NullWire proxy<br/>loopback only"]
PROXY -->|"original request, untouched"| UP["Upstream API<br/>http or https"]
UP -->|"original response"| PROXY
PROXY -->|"original response, untouched"| APP
PROXY -.->|"async copy"| DLP["DLP scrubber<br/>regex, then SHA-256 tag"]
DLP --> RING[("RAM ring<br/>50 flows")]
RING -->|"MCP over stdio<br/>scrubbed data only"| AGENT["AI agent<br/>Claude · Cursor · Windsurf"]
```
Two paths, one rule: **the real bytes go to your app, only a scrubbed copy goes to the AI.** Nothing touches the disk.
<details>
<summary><b>Diagram not rendering (mobile, Mermaid disabled)? Open the ASCII version</b></summary>
```text
╔══ [1] LOCAL APP / CLI ══════════════════════════════════════╗
║ curl · SDK · browser · your service ║▒
║ speaks plain HTTP to 127.0.0.1:8080 ║▒
╚═════════════════════════════════════════════════════════════╝▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│
▼ plain HTTP, loopback only
╔══ [2] NULLWIRE PROXY 127.0.0.1:8080 ═══════════════════════╗
║ bound with net.Listen(tcp4) + IsLoopback() check ║▒
║ forward mode : absolute URL (curl -x) ║▒
║ reverse mode : -target flag / X-Target-URL header ║▒
║ guards : DNS-rebind 403 · self-loop 508 · CONNECT 501 ║▒
╚═════════════════════════════════════════════════════════════╝▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│
▼ io.TeeReader split
┌───────────────┴────────────────┐
▼ ▼
╔══ [3a] ROUNDTRIP ══════════╗ ╔══ [3b] DLP SCRUBBER ═══════╗
║ http.Transport (isolated) ║▒ ║ async copy, capped at 64KB ║▒
║ Accept-Encoding stripped ║▒ ║ regex scrub, THEN truncate ║▒
║ Host = upstream host ║▒ ║ SHA-256(nonce|secret)[:6] ║▒
║ bytes forwarded untouched ║▒ ║ 32-byte crypto/rand nonce ║▒
╚════════════════════════════╝▒ ╚════════════════════════════╝▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│ │
▼ ▼
┌── UPSTREAM API ────────────┐ ╔══ [4] RAM RING ════════════╗
│ http:// or https:// │▒ ║ 50 slots · sync.RWMutex ║▒
│ TLS handled by Go, no MITM │▒ ║ req 8KB / resp 16KB max ║▒
└────────────────────────────┘▒ ║ newest-first reads ║▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ║ disk writes: none ║▒
│ ╚════════════════════════════╝▒
◄──┘ original response ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
returns to the app │
▼
╔══ [5] JSON-RPC 2.0 MCP (2026-07-28 + legacy) ═══════════════╗
║ initialize · discover · ping · tools/list · call ║▒
║ inspect_traffic · export_sanitized_curl · clear_ram_buffer ║▒
║ notifications → zero bytes written ║▒
╚═════════════════════════════════════════════════════════════╝▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│ │
▼ ▼
╔══ STDOUT ▓▓▓ ══════════════╗ ┌── STDERR ░░░ ──────────────┐
║ one compact JSON line ║▒ │ ANSI status bar, │▒
║ per frame, ends in \n ║▒ │ banners, errors │▒
║ NOTHING else, ever ║▒ │ (human telemetry) │▒
╚════════════════════════════╝▒ └────────────────────────────┘▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
│ │
▼ ▼
╔══ AI AGENT (MCP) ══════════╗ ┌── HUMAN ───────────────────┐
║ Claude Code · Desktop ║▒ │ you, in a terminal │▒
║ Cursor · Windsurf ║▒ └────────────────────────────┘▒
╚════════════════════════════╝▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
```
**Your app always gets the real, untouched response.** Only the AI agent's view is scrubbed.
The live telemetry bar on `stderr` (redrawn in place with `\r\033[K`):
```text
⚡ [NullWire-MCP] Proxy 127.0.0.1:8080 | RAM Ring: 14/50 | Scrubbed: 9 Tokens, 4 Cookies, 6 IPs | Disk: 0B
```
</details>
## 📊 NullWire-MCP vs. traditional MITM MCP wrappers
| | **⚡ NullWire-MCP** | Typical MITM-based MCP wrapper |
|---|---|---|
| **Third-party dependencies** | **0** (Go standard library only; `go.mod` has no `require`) | Dozens of transitive Python / Node / JVM packages |
| **Disk footprint** | **0 B**, RAM-only 50-slot ring; the code contains no file I/O | Flow files / SQLite databases on disk |
| **Secret redaction** | **Salted SHA-256 correlation tags**, so reuse is provable and the secret is unseen | None, or blind `[REDACTED]` that destroys correlation |
| **Startup** | Native static binary: no interpreter, no VM, no package resolution | Interpreter / VM cold start |
| **MCP protocol** | **2026-07-28 stateless + 2025-11-25 / 2024-11-05 legacy** | Usually legacy only |
| **HTTPS upstreams** | Reverse gateway: no root CA, no keychain changes | Install a root CA and intercept TLS |
| **Supply chain to audit** | One `main.go` | The whole dependency tree |
<sub>The right-hand column describes the general class of tools that capture traffic with a MITM proxy and hand it to an LLM. It is not a claim about any specific product. We have not published startup benchmarks; the startup row is architectural.</sub>
---
## 🚀 30-second install
**Option A: `go install`** (Go 1.23+)
```bash
go install github.com/VoxNode-ux/nullwire-mcp@latest
```
**Option B: prebuilt static binary**, verified against `SHA256SUMS.txt` from [GitHub Releases](https://github.com/VoxNode-ux/nullwire-mcp/releases):
| Platform | File |
|---|---|
| Linux x86-64 / ARM64 | `nullwire-linux-amd64` / `nullwire-linux-arm64` |
| Linux ARMv7 (Raspberry Pi etc.) / RISC-V 64 | `nullwire-linux-armv7` / `nullwire-linux-riscv64` |
| macOS Intel / Apple Silicon | `nullwire-darwin-amd64` / `nullwire-darwin-arm64` |
| Windows x86-64 / ARM64 | `nullwire-windows-amd64.exe` / `nullwire-windows-arm64.exe` |
| FreeBSD x86-64 | `nullwire-freebsd-amd64` |
```bash
# Linux example (swap the file name for your platform)
BASE=https://github.com/VoxNode-ux/nullwire-mcp/releases/latest/download
curl -LO "$BASE/nullwire-linux-amd64"
curl -LO "$BASE/SHA256SUMS.txt"
grep ' nullwire-linux-amd64$' SHA256SUMS.txt | sha256sum -c - # macOS: shasum -a 256 -c -
chmod +x nullwire-linux-amd64
sudo mv nullwire-linux-amd64 /usr/local/bin/nullwire-mcp
```
```powershell
# Windows (PowerShell)
Get-FileHash .\nullwire-windows-amd64.exe -Algorithm SHA256 # compare with SHA256SUMS.txt
```
**Option C: build from source**
```bash
git clone https://github.com/VoxNode-ux/nullwire-mcp && cd nullwire-mcp
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o nullwire-mcp .
```
### Flags
| Flag | Default | Meaning |
|---|---|---|
| `-port` | `8080` | Loopback port. The host is hard-coded to `127.0.0.1`; there is no way to bind elsewhere. `0` picks a free port. |
| `-target` | *(none)* | Default upstream base URL for reverse-gateway mode, e.g. `https://api.example.com` |
### Try it without an AI client
The MCP side lives as long as stdin stays open, so for a quick demo feed it a request on a timer:
```bash
( sleep 2
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"inspect_traffic","arguments":{"limit":5}}}'
sleep 1 ) | nullwire-mcp -port 8080 &
sleep 1
curl -s -x http://127.0.0.1:8080 http://example.com/ \
-H 'Authorization: Bearer my_secret_token_123' \
-H 'Cookie: sid=my_secret_cookie' \
-H 'X-Forwarded-For: 198.51.100.1' > /dev/null
wait
```
You'll see the live status bar on stderr and, on stdout, a flow whose headers read `Bearer [REDACTED_TOKEN#…]`, `[REDACTED_COOKIE#…]` and `[REDACTED_IP#…]`.
### Plug it into your AI client
The client launches `nullwire-mcp` as a subprocess and talks JSON-RPC over stdio. Your app then sends its traffic to `127.0.0.1:8080`. (Config file locations change between client releases, so check your client's docs if a path below is out of date.)
**Claude Code CLI**
```bash
claude mcp add nullwire -- nullwire-mcp -port 8080 -target https://api.example.com
```
**Claude Desktop** (`claude_desktop_config.json`)
```json
{
"mcpServers": {
"nullwire": {
"command": "nullwire-mcp",
"args": ["-port", "8080", "-target", "https://api.example.com"]
}
}
}
```
**Cursor** (`~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project)
```json
{
"mcpServers": {
"nullwire": {
"command": "nullwire-mcp",
"args": ["-port", "8080", "-target", "https://api.example.com"]
}
}
}
```
**Windsurf** (`~/.codeium/windsurf/mcp_config.json`)
```json
{
"mcpServers": {
"nullwire": {
"command": "nullwire-mcp",
"args": ["-port", "8080", "-target", "https://api.example.com"]
}
}
}
```
Omit `-target` if you only use forward-proxy mode or the per-request `X-Target-URL` header.
---
## 🔀 Two capture modes
### Mode 1: Forward proxy (plain HTTP)
Send a full URL through the proxy. The request is forwarded as-is and a scrubbed copy lands in the ring.
```bash
curl -x http://127.0.0.1:8080 http://httpbin.org/headers
# or for any app that honours the standard variable
HTTP_PROXY=http://127.0.0.1:8080 ./my-service
```
> `HTTPS_PROXY` makes clients send `CONNECT`, which is answered `501` by design. See [Honest scope](#-honest-scope--known-limitations). Use Mode 2 for HTTPS.
### Mode 2: Reverse gateway (HTTPS upstreams, no MITM certificates)
Talk plain HTTP to NullWire; NullWire speaks TLS to the real server using Go's normal certificate verification. Nothing to install in your trust store.
```bash
# Fixed upstream for the whole session
nullwire-mcp -target https://api.github.com
curl http://127.0.0.1:8080/users/octocat
# Or choose the upstream per request (the header is removed before forwarding)
curl -H 'X-Target-URL: https://httpbin.org' http://127.0.0.1:8080/anything?api_key=hunter2hunter2
```
Point an SDK at it by changing only its base URL:
```bash
export OPENAI_BASE_URL=http://127.0.0.1:8080/v1 # with: nullwire-mcp -target https://api.openai.com
```
The upstream `Host` header is set to the target's host, so virtual hosting and TLS SNI behave normally.
---
## 🧰 MCP tool reference
All three tools return `{"resultType":"complete","content":[{"type":"text","text":"…"}],"isError":false}`. Tool-level problems (for example an unknown flow id) set `isError` to `true` instead of failing the RPC.
### `inspect_traffic`
Recent scrubbed flows, newest first.
```json
{
"name": "inspect_traffic",
"inputSchema": {
"type": "object",
"properties": {
"limit": { "type": "integer", "default": 10, "minimum": 1, "maximum": 50 },
"method": { "type": "string", "description": "HTTP method filter, case-insensitive" },
"url_filter": { "type": "string", "description": "Substring of the scrubbed URL, case-insensitive" }
},
"additionalProperties": false
}
}
```
Example result text:
```json
{
"ring_size": 3,
"ring_capacity": 50,
"returned": 1,
"scrubbed_totals": { "tokens": 5, "cookies": 1, "ips": 1 },
"flows": [
{
"id": 3,
"timestamp": "2026-10-25T14:03:11Z",
"method": "POST",
"url": "http://[REDACTED_IP#98f1a2]:9000/v1/orders?api_key=[REDACTED_TOKEN#3c9a1e]",
"request_headers": {
"Authorization": "Bearer [REDACTED_TOKEN#a1b2c3]",
"Content-Type": "application/json",
"Cookie": "[REDACTED_COOKIE#d4e5f6]"
},
"request_body": "{\"item\":\"widget\",\"client_secret\":\"[REDACTED_TOKEN#5b7e20]\"}",
"status_code": 401,
"response_headers": { "Content-Type": "application/json" },
"response_body": "{\"error\":\"token expired\"}",
"latency_ms": 42,
"redaction_count": 5
}
]
}
```
### `export_sanitized_curl`
Rebuilds a runnable POSIX `curl` command for one flow. Every redaction tag becomes an environment-variable placeholder, and a comment legend maps each placeholder back to the tag it replaced, so you can tell which real value belongs where.
```json
{
"name": "export_sanitized_curl",
"inputSchema": {
"type": "object",
"properties": {
"flow_id": { "type": "integer", "minimum": 1, "description": "An id returned by inspect_traffic" }
},
"required": ["flow_id"],
"additionalProperties": false
}
}
```
Example result text for flow `3` above:
```bash
# Replay of flow 3 (2026-10-25T14:03:11Z)
# Set these environment variables first (distinct tags share one placeholder):
# export NULLWIRE_IP=... # was [REDACTED_IP#98f1a2]
# export NULLWIRE_AUTH_TOKEN=... # was [REDACTED_TOKEN#3c9a1e]
# export NULLWIRE_AUTH_TOKEN=... # was [REDACTED_TOKEN#5b7e20]
# export NULLWIRE_AUTH_TOKEN=... # was [REDACTED_TOKEN#a1b2c3]
# export NULLWIRE_COOKIE=... # was [REDACTED_COOKIE#d4e5f6]
curl -X 'POST' 'http://'"$NULLWIRE_IP"':9000/v1/orders?api_key='"$NULLWIRE_AUTH_TOKEN" \
-H 'Authorization: Bearer '"$NULLWIRE_AUTH_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Cookie: '"$NULLWIRE_COOKIE" \
--data-raw '{"item":"widget","client_secret":"'"$NULLWIRE_AUTH_TOKEN"'"}'
```
Hop-by-hop headers, `Host`, `Content-Length` and `Accept-Encoding` are left out so `curl` can set them itself. Truncated or encoded request bodies get a `# WARNING:` line instead of silently replaying something incomplete.
### `clear_ram_buffer`
Zeroes all 50 slots and resets the scrub counters. Flow ids keep counting upward, so an old id can never point at a new flow. Takes no arguments (`{"type":"object","properties":{},"additionalProperties":false}`).
---
## 🛡️ Security & protocol engineering deep-dive
Every item below is implemented in `main.go` and exercised by `main_test.go`.
| Measure | What it does | Why |
|---|---|---|
| **Strict loopback bind** | The listener address is hard-coded `127.0.0.1`, opened with `net.Listen("tcp4", …)`, then re-checked with `TCPAddr.IP.IsLoopback()`. The process exits if the check fails. There is no `-host` flag. | Nothing on your LAN can reach a proxy that sees your tokens. |
| **DNS-rebinding guard** | In reverse mode, any request whose `Host` header is not `127.0.0.1`, `localhost` or `::1` gets `403 Forbidden`. | A malicious web page that rebinds its domain to `127.0.0.1` can't use your gateway as an authenticated relay. |
| **Self-loop protection** | A target that resolves to the proxy's own `host:port` gets `508 Loop Detected`. | Prevents a request storm from a misconfigured `-target`. |
| **Scrub-before-truncate** | Up to **64 KB** of each body is captured, fully regex-scrubbed, and only *then* cut to **8 KB** (request) / **16 KB** (response). | A secret straddling the 8 KB boundary is already a tag by the time we cut. Truncating first would keep half a token. |
| **Per-process ephemeral nonce** | 32 bytes from `crypto/rand`, held only in RAM. Tag = first 6 hex chars of `SHA-256(nonce ‖ secret)`. | Without a salt, a hash of a low-entropy secret (a short PIN, a guessable password) can be brute-forced or rainbow-tabled from a leaked transcript. With a secret nonce nobody can precompute anything, and tags can't be linked across restarts. |
| **Strict `stdout` discipline** | `stdout` carries only single-line JSON-RPC frames ending in `\n`, written under a mutex. Banners, errors and the status bar go to `stderr`. | One stray byte on `stdout` corrupts the MCP stream. |
| **Silent notifications** | A message with no `id` (or `id: null` with a `notifications/*` method), such as `notifications/initialized`, writes **0 bytes**. | JSON-RPC forbids replying to notifications, and strict clients disconnect if you do. |
| **Original traffic untouched** | The app receives the real response through a streaming, flushing copy. Scrubbing happens on a separate copy in a background goroutine. | The proxy must never break the thing you're debugging. |
| **Isolated transport** | A dedicated `http.Transport` with no proxy, no compression and no redirect-following. | No accidental proxy chains; redirects reach your app unchanged. |
| **Hop-by-hop stripping** | `Connection`, `Proxy-*`, `TE`, `Upgrade`, … are removed in both directions. | Correct proxy behaviour per RFC 9110. |
| **Panic containment** | Capture and RPC dispatch recover from panics instead of killing the process. | A weird payload can't take your session down. |
**What gets scrubbed**
| Pattern | Tag |
|---|---|
| `Authorization`, `Proxy-Authorization`, `X-API-Key`, `API-Key`, `X-Auth-Token` header values; `Bearer` / `Basic` / `Token` credentials anywhere | `TOKEN` |
| `Cookie`, `Set-Cookie` header values (whole header hashed as one unit) | `COOKIE` |
| JWTs (`eyJ….….…`) | `TOKEN` |
| API keys: `sk-…`, `ghp_…`, `github_pat_…`, `AIza…`, `xox[baprs]-…`, `AKIA…` | `TOKEN` |
| JSON fields: `access_token`, `refresh_token`, `id_token`, `api_key`, `apikey`, `secret`, `client_secret`, `password`, `passwd`, `session_id`, `auth_token` | `TOKEN` |
| Query / form params: `api_key`, `apikey`, `token`, `access_token`, `refresh_token`, `id_token`, `secret`, `client_secret`, `password`, `passwd`, `key` | `TOKEN` |
| `user:password@host` in URLs | `TOKEN` |
| IPv4 addresses (`127.0.0.1` and `0.0.0.0` are left alone) | `IP` |
---
## ⚖️ Honest scope & known limitations
We would rather you hear these from us than discover them in production.
- **`HTTP CONNECT` returns `501 Not Implemented`, by design.** Inspecting HTTPS through `CONNECT` means man-in-the-middling TLS, which means generating a root CA and getting it into your OS or browser trust store. That is key material and keychain pollution, the opposite of a zero-footprint tool. The **reverse gateway** gets the same visibility without any of that: point your app at `http://127.0.0.1:8080` and let NullWire do the TLS to the upstream.
- **No WebSocket / `Upgrade` pass-through.** `Upgrade` and `Connection` are treated as hop-by-hop headers and stripped, so upgraded protocols won't work through the proxy. Plain request/response and streaming responses (e.g. SSE) are supported.
- **Android / Termux:** static Go binaries have no DNS resolver config on Android, so upstream hostnames fail with `502 … lookup … connection refused`. Run inside `termux-chroot` (`pkg install proot`), which provides a resolver. Linux desktops, macOS, Windows and FreeBSD are not expected to be affected.
- **Redaction is pattern-based, not magic.** It catches the shapes in the table above. A secret in a format we don't recognise, in a field we don't know, or split across a body boundary can pass through. Treat the scrubber as a strong seatbelt, not a guarantee, and test it with your own traffic. IPv6 addresses are not redacted yet. IPv4-shaped strings that aren't addresses (some version numbers) can be redacted as false positives.
- **Tags are 24 bits.** Six hex characters are plenty to tell apart the handful of secrets in a 50-slot buffer, but collisions are possible in principle. They are for correlation, not identification.
- **Bodies are bounded.** Only the first 64 KB of each body is captured, and stored bodies are cut to 8 KB / 16 KB. Responses that arrive with a `Content-Encoding` (e.g. gzip) are stored as a short placeholder rather than decoded. NullWire strips `Accept-Encoding` from forwarded requests so most servers reply uncompressed, but it can't force them to.
- **"RAM-only" means the program never writes files.** `Disk: 0B` in the status bar is a design invariant (the source has no file I/O), not a measured counter. The OS can still swap memory pages or write crash dumps; NullWire doesn't control that.
- **The ring is ephemeral.** The 50 most recent flows vanish when the process exits. That is the point.
- **Your app sees raw traffic.** NullWire protects what the *AI agent* sees, not what your local app sees. It isn't a security boundary for the app itself.
- **Protocol coverage is what's documented.** Initialization, discovery, ping, `tools/list` and `tools/call` for the 2026-07-28 / 2025-11-25 / 2024-11-05 specs. Resources, prompts and sampling aren't implemented and return `-32601`.
- **Release binaries are unsigned.** Verify them with `SHA256SUMS.txt`. On macOS you may need `xattr -d com.apple.quarantine ./nullwire-darwin-arm64`.
---
## 🧪 Tested on
| Platform | What has been verified |
|---|---|
| Linux x86-64 | CI: `go vet`, unit tests with `-race`, MCP smoke test, end-to-end redaction |
| Windows x86-64 | CI: MCP smoke test, end-to-end redaction |
| macOS (Apple Silicon, Intel) | CI: MCP smoke test, silent notifications, end-to-end redaction |
| FreeBSD x86-64 | CI (FreeBSD VM): MCP smoke test, silent notifications, end-to-end redaction |
| Linux ARMv7 & RISC-V 64 | CI (QEMU user-mode): MCP smoke test, silent notifications, end-to-end redaction |
| Linux ARM64 (Android / Termux) | Manual on-device: live upstream proxy, redaction tags, `export_sanitized_curl`, reverse gateway and guards |
| Windows ARM64 | Cross-compiled in CI only; not run. Reports welcome |
CI results (including raw smoke-test output) are on the [Actions tab](https://github.com/VoxNode-ux/nullwire-mcp/actions).
---
## ✅ Verification & testing
```bash
go vet ./...
go test -v ./...
go test -v -race ./... # the race detector needs cgo (gcc/clang) on most platforms
```
The suite uses only the standard library (`net/http/httptest`) and covers:
- Deterministic tagging: equal secrets share a tag, different secrets don't, nothing leaks, `127.0.0.1` survives.
- Ring-buffer wrap-around eviction and `Clear()` with monotonic ids.
- A full reverse-gateway round trip: `Accept-Encoding` stripped, tokens scrubbed in the ring, a valid sanitized `curl` exported.
- MCP dispatch: `server/discover`, `initialize`, `ping`, `tools/list`, `tools/call`, error codes, and **zero stdout bytes for notifications**.
CI (`.github/workflows/release.yml`) runs `go vet` and `go test -race`, cross-compiles static `CGO_ENABLED=0` binaries for nine targets (Linux amd64/arm64/armv7/riscv64, macOS amd64/arm64, Windows amd64/arm64, FreeBSD amd64), and publishes them with `SHA256SUMS.txt` on `v*` tags.
---
## ❓ FAQ
<details>
<summary><b>Does the AI ever see my real token?</b></summary>
Not through NullWire's tools. The agent receives only tags like `[REDACTED_TOKEN#a1b2c3]`. Redaction is pattern-based, so a secret in a shape the scrubber does not recognise could pass through. See [Honest scope](#-honest-scope--known-limitations).
</details>
<details>
<summary><b>Does it change what my app receives?</b></summary>
Your app gets the real response through a streaming copy, and scrubbing runs on a separate copy in a background goroutine. One small detail: NullWire strips `Accept-Encoding` on the upstream request, so responses normally arrive uncompressed. See [Honest scope](#-honest-scope--known-limitations).
</details>
<details>
<summary><b>How do I debug HTTPS APIs?</b></summary>
Use [Mode 2, the reverse gateway](#mode-2-reverse-gateway-https-upstreams-no-mitm-certificates): your app talks plain HTTP to `127.0.0.1`, NullWire talks verified TLS to the real server. No root CA, nothing in your trust store. `HTTPS_PROXY` (`CONNECT`) is answered `501` by design.
</details>
<details>
<summary><b>Can the agent replay a request without knowing the secrets?</b></summary>
Yes. `export_sanitized_curl` rebuilds a runnable `curl` where every tag becomes an environment variable such as `$NULLWIRE_AUTH_TOKEN`. You set the real values; the agent never has them.
</details>
<details>
<summary><b>Where is my traffic stored?</b></summary>
In a 50-slot RAM ring. It disappears when the process exits, and `clear_ram_buffer` wipes it on demand. The program contains no file I/O.
</details>
<details>
<summary><b>Can I expose it on my network?</b></summary>
No. The listener is hard-coded to `127.0.0.1` and checked with `IsLoopback()`; there is no flag to bind elsewhere.
</details>
---
## 🤝 Contributing
Issues and PRs are welcome. The whole project is one `main.go` plus tests, so it is easy to read end to end.
```bash
go vet ./... && go test -v ./...
```
Good places to help:
- **Found a secret format that slips through?** Open an issue with a *fake* example of its shape (never a real secret).
- **Scrubber coverage:** more key formats, IPv6 addresses (currently IPv4 only).
- **Platform reports:** Windows ARM64 is cross-compiled in CI but not run. Reports are very welcome.
- **Client recipes:** config snippets for other MCP clients.
---
## 📄 License
[MIT](LICENSE) © 2026 VoxNode-ux
<div align="center">
**If NullWire saved you from pasting a live token into a chat window, a ⭐ helps other engineers find it.**
</div>
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues