zeta-1 is a transparent dev proxy with Claude Code MCP integration.
Run bun run cli.ts proxy localhost:3000. It sits between your browser and your dev server on port 4000. Every HTTP request your app makes flows through it. It injects a floating panel into every HTML page. Claude Code connects via MCP and can observe, intercept, and modify traffic — live.
Two terminals:
# Terminal 1 — cd into your app, run Z1 with whatever starts your dev server
cd /path/to/your/app
bun run /path/to/z1/cli.ts proxy localhost:3000 --spawn "<your dev command>"
# Terminal 2 — Claude Code (reads .mcp.json automatically)
claude
# Flags:
# --spawn "cmd" start your dev server as a child process, killed on exit
# --port 4000 proxy port (default: 4000)
# --expose bind to 0.0.0.0 (default: 127.0.0.1 only)
# --no-console disable browser console capture
# --allow-remote allow non-localhost targets
# --allow-https allow HTTPS targetsThe MCP server is configured per-project via .mcp.json at the repo root — no global config needed.
.mcp.json is already committed. When you open this project in Claude Code it will prompt you to approve the server on first use.
To add it manually or reset it:
claude mcp add --transport stdio zeta1 --scope project -- bun run src/mcp/server.ts --ws ws://localhost:4000/__devproxy/wsOr edit .mcp.json directly:
{
"mcpServers": {
"zeta1": {
"type": "stdio",
"command": "bun",
"args": ["run", "src/mcp/server.ts", "--ws", "ws://localhost:4000/__devproxy/ws"]
}
}
}The proxy must be running (bun run cli.ts proxy localhost:3000) before the MCP tools (z1_observe, z1_console, etc.) will work.
Browser (localhost:4000)
│ HTTP
▼
src/proxy/server.ts ← Bun.serve: intercepts, applies rules, injects client bundle
│ forward
▼
Dev server (localhost:3000)
src/proxy/server.ts
│ WebSocket /__devproxy/ws
▼
src/proxy/control.ts ← WS hub: ring buffers, broadcast to all clients
┌──── connected clients ────┐
│ │
Browser panel src/mcp/server.ts
(floating UI) (stdio JSON-RPC → Claude Code)
Browser page
└── public/devproxy-client.js ← built from client/ at startup
client/index.ts entry: wires all patches + panel + WS
client/fetch-patch.ts patches window.fetch + XHR, CORS rewriting
client/console-patch.ts patches console.{log,warn,error,info,debug}
client/fiber.ts React fiber tree walk (component names, knownComponents)
client/panel.ts floating panel: Network tab + Console tab (read-only)
client/ws.ts WebSocket singleton to /__devproxy/ws
cli.ts Entry point: arg parsing, client bundle build, proxy startup
src/
proxy/
server.ts Bun.serve: HTTP proxy, WS relay, HMR passthrough, HTML injection
control.ts WS hub: clients set, ring buffers (captures×500, console×500),
header redaction, broadcast helpers
ruleStore.ts In-memory Rule store (delay / respond / drop / throttle)
store.ts OverrideRule store (vestigial — kept for WS message compat)
mcp/
server.ts stdio JSON-RPC 2.0 MCP server — 7 tools
client/
index.ts Bundle entry
fetch-patch.ts window.fetch + XHR patches, CORS rewriting, request attribution
console-patch.ts console.* patches with rate limiting + depth-limited serialiser
fiber.ts React DevTools hook: getCurrentFiberName, knownComponents
panel.ts Floating panel UI (read-only, draggable, collapsible)
ws.ts WS singleton, onMessage, onStateChange, isConnected
tsconfig.json Browser tsconfig (lib: ESNext + DOM)
public/
devproxy-client.js Built at proxy startup — not committed
docs/
src/components/ Vite + React marketing site
Hero.tsx Full-viewport hero
FlowDiagram.tsx Architecture diagram section
Features.tsx 3-column capability grid
TerminalDemo.tsx Resizable animated terminal demo
Install.tsx Quick-start commands
Nav.tsx / Footer.tsx Shared chrome
DocsPage.tsx Docs page with sidebar
- Browser
fetch/XHR → intercepted byfetch-patch.tsin the injected bundle attributeToComponent()runs: fiber name → effect owner → stack trace (best-effort)request_capturedWS message sent to proxy with{ method, url, component }- URL rewritten if needed (host-swap for dev server, CORS proxy for external)
- Proxy
server.tsreceives the HTTP request:matchRule()→ apply delay / respond / drop / throttle if matched- Otherwise forward to target dev server
- Response buffered → headers redacted →
broadcastCapture()→ all WS clients
| Direction | Message | Purpose |
|---|---|---|
| browser → proxy | request_captured |
HTTP request observed by fetch-patch |
| browser → proxy | console_entry |
console.* event from console-patch |
| browser → proxy | components_sync |
React fiber component list (on every commit) |
| proxy → all | captures_sync |
bulk replay on connect (last 50) |
| proxy → all | rules_sync |
current behavior rules |
| proxy → all | console_sync |
bulk console replay on connect (last 50) |
| proxy → all | components_sync |
empty initial push on connect |
| MCP → proxy | rule_inject |
add a rule |
| MCP → proxy | rule_clear |
remove rule by match string |
| MCP → proxy | rule_clear_id |
remove rule by id |
| MCP → proxy | rules_clear_all |
remove all rules |
| Tool | Description |
|---|---|
z1_observe |
List captured requests. Params: n, method, path filter |
z1_inject |
Add a rule: { match, action, params } |
z1_clear |
Clear captures and/or rules. Params: captures, rules booleans |
z1_rules |
List active rules |
z1_console |
List browser console entries. Params: n, level filter |
z1_mock |
Register a mock response: { match, status, body } (uses respond rule) |
z1_components |
List all React components currently in the fiber tree (live) |
match format: "METHOD path" or "* path" or just "path" (wildcard method).
Matching precedence: exact method > * > prefix match (longest first).
| Action | Params | Behaviour |
|---|---|---|
delay |
{ ms: number } |
adds latency, then forwards to target |
respond |
{ status, body, headers? } |
short-circuits — never reaches target |
drop |
— | returns 503 with X-Z1-Drop: true |
throttle |
{ kbps: number } |
streams response at limited bandwidth |
attributeToComponent() in fetch-patch.ts tries 4 strategies in order:
- Static URL map (
staticAttributionMap) — hardcoded patterns, useful for known apps - Current fiber name — reads the React dispatcher at render time
- Effect owner — component name carried into
useEffectcallbacks - Stack trace parsing — PascalCase names or
useXxxhooks in the call stack
Unreliable in: minified builds, Next.js App Router server components, heavily wrapped callbacks. Treat as a hint, not ground truth. The ~ prefix in the panel signals this.
Built with Bun.build() on every proxy start. Entry: client/index.ts. Output: public/devproxy-client.js. The bundle target is browser — Bun transpiles without type-checking so DOM errors in client/ are non-fatal.
client/tsconfig.json exists separately with lib: ["ESNext", "DOM"] for IDE type checking. The root tsconfig.json covers only src/ and cli.ts (Bun server code, no DOM).
zeta-1 is a local development tool only. It is not hardened for shared, staging, or production environments. This section documents every known surface, what is currently mitigated, and what is not.
The realistic attacker is not an external adversary — it is accidental exposure: running the proxy with --expose on a shared network, or a confused engineer pointing it at a real server. The goals of the security model are:
- Credentials never appear in Claude's context window
- The proxy never touches a non-local target unless explicitly overridden
- PII in transit stays in the developer's machine
- The attack surface on the local network is minimal by default
Default: binds to 127.0.0.1:4000 — not reachable from any other machine.
--expose: binds to 0.0.0.0 — reachable from anything on the same network. When this flag is set, the following consequences apply:
- Any machine on the LAN can read all captured requests and console logs via
GET /__devproxy/ws - Any machine on the LAN can inject rules (delay, mock, drop) via the same WS
- Any machine on the LAN can read all traffic through the CORS proxy at
/__devproxy/cors
The --expose flag prints a warning at startup. There is currently no IP allowlist, no firewall rule, and no authentication on the WS endpoint.
Never use --expose on a coffee shop, conference, or shared office network.
zeta-1 makes zero AI calls itself. All AI reasoning happens in the Claude Code session. However, what z1_observe and z1_console return goes directly into Claude's context window — treat it as if you are pasting it into a chat.
Currently forwarded to Claude via MCP tools:
| Data | Tool | Mitigated? |
|---|---|---|
| Request path, method, status, timing | z1_observe |
N/A — not sensitive |
| Request headers (Authorization, Cookie, etc.) | z1_observe |
✓ Redacted automatically |
| Response headers | z1_observe |
✓ Redacted automatically |
| Component attribution (fiber name) | z1_observe |
N/A — not sensitive |
| Browser console arguments (log/warn/error) | z1_console |
✗ Not redacted |
| Console stack traces | z1_console |
✗ Not redacted |
| React component names from fiber tree | z1_components |
N/A — not sensitive |
| Request bodies | not captured | N/A — not implemented |
| Response bodies | not captured | N/A — not implemented |
The most dangerous vector today is console logs. Developers routinely log decoded JWTs, API responses, user objects, and error details. All of this flows to Claude unless --no-console is set.
Implemented in control.ts — redactHeaders() runs on every CaptureEvent before it is stored in the ring buffer or broadcast to any WS client (panel or MCP server).
Redacted headers (case-insensitive):
authorization cookie set-cookie
x-api-key x-auth-token x-access-token
proxy-authorization x-csrf-token x-session-id
x-secret
These are replaced with the string [REDACTED]. The key is preserved so Claude can see that an auth header was present, but not its value.
Not covered: custom application headers that carry tokens outside this list (e.g. x-user-token, x-tenant-id). If your app uses non-standard auth headers, add them to SENSITIVE_HEADERS in control.ts.
Response bodies are not captured at all — only metadata. If body capture is added in the future, it must go through a scrubbing pass before storage.
console-patch.ts intercepts all five log levels (log, info, warn, error, debug) and forwards them to the proxy WS, which then makes them available to z1_console — and therefore to Claude.
Common PII found in console logs in real apps:
console.log(user)— full user objects including email, name, internal IDsconsole.log(token)— raw JWTs or session tokensconsole.error(err)— stack traces with file paths and sometimes request payloadsconsole.log(response.data)— full API response bodies
Mitigations available today:
--no-consoleflag — disables all console capture at the inject-script level.window.__z1_no_console = trueis set before the bundle loads, sopatchConsole()is never called.
Not yet implemented:
- Per-level filtering (e.g. capture
erroronly, skiplog) - Pattern-based redaction (e.g. strip anything matching a JWT regex)
- Opt-in body scrubbing for common PII fields
Every HTML response served through the proxy receives an injected <script src="/__devproxy/client.js"> tag. To make this work, the proxy strips the Content-Security-Policy response header from every HTML response.
Implications:
- Pages that rely on CSP for XSS protection lose that protection while behind the proxy
- The injected script runs with full page access — same origin, same cookies, same DOM
- If the target server sends
X-Frame-OptionsorCOOP/COEPheaders that conflict with the injection, these are also stripped
This is intentional for a local dev tool. The trade-off is acceptable because:
- The proxy only runs locally (or on a trusted network with
--expose) - The developer controls both the proxy and the target
Absolute rule: never run zeta-1 in front of a server that real users are hitting, even temporarily. CSP stripping on a production login page is a critical vulnerability.
The client bundle rewrites cross-origin fetch requests to /__devproxy/cors?url=<encoded>. This allows the proxy to forward requests to any external URL — bypassing the browser's CORS restrictions entirely.
This means:
- Any script running on the proxied page can make requests to any URL on the internet through
/__devproxy/cors, with no CORS restrictions - If
--exposeis active, any machine on the network can use/__devproxy/corsas an open HTTP proxy to reach arbitrary external URLs
There is currently no allowlist for the CORS proxy. Intended future mitigation: restrict /__devproxy/cors to hostnames declared at startup (the target's known API domains).
/__devproxy/ws is an unauthenticated WebSocket endpoint. Any client that can reach it can:
- Read the last 50 captured requests and console entries (sent on connect as
captures_sync,console_sync) - Receive all future captures and console entries in real time
- Inject behavior rules (
rule_inject) — includingrespondrules that replace any endpoint's response with arbitrary JSON - Clear all rules and captures
- Read the full React component tree via
components_sync
Current exposure: low by default because the proxy binds to 127.0.0.1. With --expose, this becomes a significant risk on shared networks.
Planned mitigation (not yet implemented): --ws-token <secret> flag — requires Authorization: Bearer <secret> on the WS upgrade request. The MCP server would pass the token via --ws-token at startup.
The CLI refuses to start if the target looks dangerous:
- HTTPS target → rejected unless
--allow-httpsis passed (signals a remote/production server) - Non-localhost hostname → rejected unless
--allow-remoteis passed
These checks happen in runProxy() in cli.ts before anything else. They are the first line of defense against accidentally pointing the proxy at a real server.
All state lives in memory:
- Captures ring buffer (max 500)
- Console entries ring buffer (max 500)
- Active rules (in-memory Map)
- Known React components (in-memory Set)
Nothing is written to disk. Nothing persists across proxy restarts. There are no log files. There is no database. If you need to share a capture session, you must do so manually (e.g. copy z1_observe output).
| Risk | Severity | Status | Notes |
|---|---|---|---|
Credentials in z1_observe output |
High | ✓ Mitigated | Sensitive headers auto-redacted in control.ts |
| PII in console logs forwarded to Claude | High | ⚠ Partial | Use --no-console; per-pattern redaction not implemented |
| WS control plane unauthenticated | Medium | ⚠ Localhost-only | --ws-token not yet implemented |
| CORS proxy open to any URL | Medium | ⚠ Unmitigated | No hostname allowlist yet |
| CSP stripped from HTML responses | Medium | Deliberate | Dev-only trade-off; documented |
--expose on untrusted network |
High | ⚠ User responsibility | Warning printed at startup |
| Proxy pointed at production server | Critical | ✓ Blocked by default | --allow-https + --allow-remote required |
| Response bodies captured and sent to Claude | High | ✓ Not implemented | Bodies not captured; mitigate before adding |
| Custom auth headers outside redact list | Medium | ⚠ Unmitigated | Add to SENSITIVE_HEADERS in control.ts |
- Runtime: Bun only.
Bun.serve,Bun.build,Bun.file. No Node-specific APIs. - No framework: raw
fetchfor all HTTP. No Express, no Hono. - In-memory only: ring buffers in
control.ts. No persistence, no database. - MCP transport: stdio, raw JSON-RPC 2.0. No SDK.
- No AI calls: zeta-1 makes zero AI calls. Claude Code drives it via MCP.
- WS hub: all connected clients share the same broadcast.
control.tsholdsSet<ServerWebSocket>.
Vite + React. cd docs && npm run dev to develop, npm run build to build.
Design system in docs/src/index.css:
- Background
#080808, accent red#f87171, blue#60a5fa, green#4ade80 - Fonts: GT Walsheim (sans), Geist Mono (mono)
- framer-motion for all animations
- Add inline styles for one-offs — don't add new CSS classes for single-use styles
store.ts(OverrideRule) is vestigial — kept only for WS message type compatibilitysetZ1PortandsetAppDirincontrol.tsare stubsstaticAttributionMapinfetch-patch.tshas hardcoded example URL patterns — update for your app- Client bundle rebuilds on every proxy start (no incremental build / watch mode)