Skip to content

Latest commit

 

History

History
393 lines (285 loc) · 18.1 KB

File metadata and controls

393 lines (285 loc) · 18.1 KB

CLAUDE.md — zeta-1

What it is

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.

Running it

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 targets

Claude Code MCP setup (per-project)

The 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/ws

Or 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.

Architecture

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

File map

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

Request lifecycle

  1. Browser fetch/XHR → intercepted by fetch-patch.ts in the injected bundle
  2. attributeToComponent() runs: fiber name → effect owner → stack trace (best-effort)
  3. request_captured WS message sent to proxy with { method, url, component }
  4. URL rewritten if needed (host-swap for dev server, CORS proxy for external)
  5. Proxy server.ts receives the HTTP request:
    • matchRule() → apply delay / respond / drop / throttle if matched
    • Otherwise forward to target dev server
  6. Response buffered → headers redacted → broadcastCapture() → all WS clients

WS message types

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

MCP tools (src/mcp/server.ts)

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)

Rule engine (src/proxy/ruleStore.ts)

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

Component attribution (best-effort)

attributeToComponent() in fetch-patch.ts tries 4 strategies in order:

  1. Static URL map (staticAttributionMap) — hardcoded patterns, useful for known apps
  2. Current fiber name — reads the React dispatcher at render time
  3. Effect owner — component name carried into useEffect callbacks
  4. Stack trace parsing — PascalCase names or useXxx hooks 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.

Client bundle

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).

Security & privacy

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.


Threat model

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:

  1. Credentials never appear in Claude's context window
  2. The proxy never touches a non-local target unless explicitly overridden
  3. PII in transit stays in the developer's machine
  4. The attack surface on the local network is minimal by default

1. Network binding

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.


2. What Claude actually sees

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.


3. Sensitive header redaction

Implemented in control.tsredactHeaders() 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.


4. Console log PII

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 IDs
  • console.log(token) — raw JWTs or session tokens
  • console.error(err) — stack traces with file paths and sometimes request payloads
  • console.log(response.data) — full API response bodies

Mitigations available today:

  • --no-console flag — disables all console capture at the inject-script level. window.__z1_no_console = true is set before the bundle loads, so patchConsole() is never called.

Not yet implemented:

  • Per-level filtering (e.g. capture error only, skip log)
  • Pattern-based redaction (e.g. strip anything matching a JWT regex)
  • Opt-in body scrubbing for common PII fields

5. HTML injection and Content-Security-Policy

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-Options or COOP/COEP headers 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.


6. The CORS proxy (/__devproxy/cors)

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 --expose is active, any machine on the network can use /__devproxy/cors as 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).


7. The WebSocket control plane

/__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) — including respond rules 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.


8. Target safety checks

The CLI refuses to start if the target looks dangerous:

  • HTTPS target → rejected unless --allow-https is passed (signals a remote/production server)
  • Non-localhost hostname → rejected unless --allow-remote is 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.


9. Ephemerality

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).


10. Full risk register

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

Conventions

  • Runtime: Bun only. Bun.serve, Bun.build, Bun.file. No Node-specific APIs.
  • No framework: raw fetch for 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.ts holds Set<ServerWebSocket>.

docs/ (marketing site)

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

Known rough edges

  • store.ts (OverrideRule) is vestigial — kept only for WS message type compatibility
  • setZ1Port and setAppDir in control.ts are stubs
  • staticAttributionMap in fetch-patch.ts has hardcoded example URL patterns — update for your app
  • Client bundle rebuilds on every proxy start (no incremental build / watch mode)