An LLM-as-a-judge HTTP proxy to secure agents in production
  • Go 79.9%
  • TypeScript 18.2%
  • Shell 1%
  • Makefile 0.8%
Find a file
Matt Van Horn 44738f973b
feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13)
* feat(observability): introduce metrics package with counters and /metrics endpoint

Adds an optional OpenTelemetry metric surface with a Prometheus bridge
exporter, gated behind observability.metrics.enabled (default off).

Three counters are instrumented:
- crabtrap_rate_limit_hits_total (per-IP limiter rejections)
- crabtrap_llm_circuit_breaker_trips_total{provider}
- crabtrap_approval_decisions_total{outcome,mode}

Each instrumented package (proxy, llm, approval) defines a narrow
observer interface rather than importing the metrics package directly,
keeping leaf packages free of observability dependencies. A value-type
shim in cmd/gateway/main.go binds the three interfaces to the shared
*metrics.Registry.

The Registry is nil-safe by construction: every Record* method checks
the receiver first, and New() returns (nil, err) on any partial-init
failure. Disabled mode performs no allocations on the hot path.

Refs #12.

* feat(observability): full metric surface — histograms, CB state gauge, build info, cookie-auth /metrics, operator docs

Builds out the observability work started earlier to cover everything
issue #12 asks for in a single PR.

Expanded metric surface (7 metrics + build info):
- crabtrap_rate_limit_hits_total                          (counter)
- crabtrap_llm_circuit_breaker_trips_total{provider}      (counter)
- crabtrap_llm_circuit_breaker_state{provider}            (gauge, 0/1)
- crabtrap_approval_decisions_total{outcome,mode}         (counter)
- crabtrap_judge_latency_seconds{provider,model}          (histogram)
- crabtrap_approval_latency_seconds{mode,outcome}         (histogram)
- crabtrap_build_info{version,commit,go_version}          (gauge)

Other changes:
- Circuit-breaker emits state-change callbacks on trip, reset, and
  half-open probe transitions. Edge detection is captured under cbMu
  and fired outside the lock so observer callbacks never deadlock.
- Latency is recorded on every judge call regardless of success or
  failure, so tail-latency alerts surface degradation before errors.
- /metrics is cookie-authenticated by default (reuses the admin token).
  auth: none requires i_know_this_is_public: true at validation time
  because label values reveal operational posture (configured
  providers, allow/deny ratios).
- Build version/commit/date variables on main are settable via
  -ldflags -X, surfaced via crabtrap_build_info so operators can
  correlate metric anomalies with deployments.
- docs/observability.md documents the full metric catalog, scrape
  config examples, alert suggestions, and cardinality guarantees.
- README has a concise Observability section linking to the doc.
- Integration test exercises every instrument via a real httptest
  server and scrapes /metrics over HTTP.

Closes #12.

* fix(observability): correct circuit breaker state gauge after failed half-open probe

Greptile spotted (#13 review) that the state gauge went stale after a
failed half-open probe: circuitBreakerOpen() optimistically flipped the
gauge to closed, and RecordFailure's edge guard (cbOpenedAt.IsZero())
suppressed the re-trip, leaving operators seeing "closed" while the
breaker was actually rejecting calls until the next cooldown.

Fix tracks halfOpenProbeOutstanding explicitly:
- circuitBreakerOpen sets it on the first probe in the cooldown window
- RecordFailure clears it and re-emits state=true on a failed probe
- RecordSuccess clears it and suppresses the redundant state=false since
  circuitBreakerOpen already emitted it

Regression test in resilience_test.go exercises trip -> half-open ->
probe-fails and asserts the three expected state transitions.

* admin: require isAdmin in RequireAuthCookie to match docstring intent

Greptile P1 (#13 review) flagged that RequireAuthCookie validated the
token but discarded the isAdmin flag, so any authenticated user could
scrape /metrics — strictly weaker than the /admin/* routes (which use
requireAdmin and return 403 for non-admins).

Tighten the check to match the docstring's stated intent of "valid
admin auth cookie": capture the isAdmin flag, return 403 Forbidden on
non-admin cookies, matching the pattern in requireAdmin.

The P1 #1 concern from the same greptile review (state gauge stale
after failed half-open probe) was already addressed in 682bfed via
the halfOpenProbeOutstanding flag + regression test.

Verified: go build ./internal/admin clean, go vet clean,
internal/llm and internal/metrics tests pass. Admin integration tests
require Docker/testcontainers and were deferred to CI.

* refactor(observability): separate /metrics listener + goreleaser/Makefile + buildDate

Addresses all five review asks on PR #13:

1. /metrics now binds 127.0.0.1:9090 by default on a standalone HTTP listener.
   The admin port no longer serves /metrics. The auth toggle and the
   i_know_this_is_public acknowledgement flag are gone -- exposure is
   controlled by the listen bind address, so Prometheus scrapers do not need
   admin credentials. Loopback default keeps the surface private without
   operator action; operators who scrape from another host set listen
   explicitly.

2. & 4. .goreleaser.yaml and Makefile both inject version/commit/buildDate via
   -ldflags -X main.<var>. Makefile derives VERSION/COMMIT/DATE from git and
   exposes them as overridable variables. Adds gateway --version flag for
   operator verification.

3. crabtrap_build_info now carries a build_date label fed from the linker
   var. RecordBuildInfo signature gained a buildDate parameter; docs and
   tests updated.

5. metrics.New() failure logs and continues instead of exiting. The LLM
   trust-boundary data plane must never fail to start because the
   observability subsystem could not initialise. Adds initMetrics() helper
   and a metricsRegistryFactory hook so the failure path is unit-tested
   without depending on duplicate registrations.

Verified locally on darwin/arm64: go vet, staticcheck, go test -race -p 1
./..., make build-go, ./gateway --version, goreleaser check, and a manual
smoke run that confirms scrape on :9090 returns 200 with no auth, the admin
port returns 404 on /metrics, and crabtrap_build_info carries the build_date
label.

* fix: remove dead RequireAuthCookie method

/metrics moved to a standalone unauthenticated listener in 38688f0,
leaving this admin-auth wrapper unused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Àbéjídé Àyodélé <aabejide@brex.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 10:30:33 -05:00
.github/workflows CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
assets CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
cmd/gateway feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
config feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
docs feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
internal feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
pkg/types fix: normalize nil static rule methods to prevent policy editor crash (#8) (#11) 2026-04-23 10:29:25 -05:00
scripts CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
web fix: normalize nil static rule methods to prevent policy editor crash (#8) (#11) 2026-04-23 10:29:25 -05:00
.dockerignore CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
.gitignore CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
.goreleaser.yaml feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
AGENTS.md CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
CONTRIBUTING.md CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
DESIGN.md CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
docker-compose.yml update quickstart to include creating admin and agent users. 2026-04-20 20:57:26 +00:00
Dockerfile.goreleaser CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
go.mod feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
go.sum feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
IMPLEMENTATION.md CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
LICENSE CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
Makefile feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
QUICKSTART.md update quickstart to include creating admin and agent users. 2026-04-20 20:57:26 +00:00
README.md feat(observability): OpenTelemetry metrics with Prometheus endpoint (#13) 2026-04-28 10:30:33 -05:00
SECURITY.md CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
test-proxy.sh CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
TESTING.md CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00
TROUBLESHOOTING.md CrabTrap AI agent HTTP/HTTPS security proxy 2026-04-17 18:58:23 +00:00

CrabTrap

CrabTrap logo

An HTTP/HTTPS proxy that sits between AI agents and external APIs, evaluating every outbound request against security policies before it reaches the internet.

If you run AI agents that call external services — Slack, Gmail, GitHub, or anything else — CrabTrap gives you guardrails. It intercepts every outbound HTTP/HTTPS request, checks it against deterministic rules and an LLM-based policy judge, and either forwards it or blocks it with a reason. Every request and decision is logged to PostgreSQL for a complete audit trail.

CrabTrap request flow

Quickstart

CrabTrap runs as a Docker container alongside PostgreSQL. See QUICKSTART.md for the full walkthrough — the short version:

docker compose up -d                                                    # start CrabTrap + Postgres
docker compose cp crabtrap:/app/certs/ca.crt ./ca.crt                   # copy the generated CA cert
# create test-admin admin user and store their web_token in a variable
admin_token=$(docker compose exec -it crabtrap ./gateway create-admin-user test-admin \
    | tail -n1 | cut -d" " -f2)
token=$(curl -X POST http://localhost:8081/admin/users \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer ${admin_token}" \
    -d '{"id": "alice@example.com", "is_admin": false}' \
    | jq -r '.channels[] | select(.channel_type == "gateway_auth") | .gateway_auth_token')
# test with
curl -x http://${token}:@localhost:8080 \
    --cacert ca.crt https://httpbin.org/get

The proxy listens on localhost:8080, the admin UI is at localhost:8081 and you can login to it with the $admin_token.

How It Works

  1. Agent connects — set HTTP_PROXY and HTTPS_PROXY to point at CrabTrap
  2. TLS termination — CrabTrap generates a per-host certificate from a custom CA and decrypts the request
  3. Static rules — the request is matched against URL pattern rules (prefix, exact, or glob). If a rule matches, the decision is immediate — no LLM call. Deny rules always take priority over allow.
  4. LLM judge — if no static rule matches, the request is evaluated by an LLM against the agent's natural-language security policy. Allowed requests are forwarded; denied requests get a 403 with the reason.
  5. Audit logged — every request, decision, and response is recorded in PostgreSQL

Features

Security

  • HTTPS interception — transparent MITM proxy with custom TLS server certificate generation
  • SSRF protection — blocks requests to private networks (RFC 1918, loopback, link-local, Carrier-Grade NAT, IPv6 ULA/NAT64/6to4) with DNS-rebinding prevention
  • Prompt injection defense — request payloads are JSON-encoded and policy content is JSON-escaped before being sent to the LLM judge
  • Per-IP rate limiting — token bucket rate limiter (default 50 req/s, burst 100)

Policy Evaluation

  • Two-tier evaluation — deterministic static rules are checked first; the LLM judge is only invoked if no rule matches
  • Static rules — prefix, exact, and glob URL pattern matching with optional HTTP method filters
  • Per-agent LLM policies — natural-language security policies evaluated via LLM
  • Circuit breaker — trips after 5 consecutive LLM failures, reopens after 10s cooldown
  • Configurable fallback — deny (default) or passthrough when the LLM judge is unavailable

Operations

  • Policy builder — an agentic loop that analyzes observed traffic and drafts security policies automatically
  • Eval system — replay historical audit log entries against a policy to measure accuracy
  • Web UI — audit trail viewer, policy editor, eval results, and agent management

What CrabTrap Does NOT Do

  • Not a WAF or inbound firewall — CrabTrap is a forward proxy (outbound-only) for agent-originated traffic. It does not inspect inbound requests to your services.
  • Does not redact sensitive data — the proxy sees all request content in cleartext, including headers like Authorization and Cookie. This is by design; the trust boundary is the proxy itself.
  • Does not provide human-in-the-loop approval — there is no approval queue, no Slack prompts, and no escalation path. Decisions are made automatically by static rules and the LLM judge.
  • Does not filter API responses — only outbound requests are evaluated. Responses from upstream APIs are streamed back to the agent unexamined.
  • Does not inspect WebSocket frames — only the WebSocket upgrade request is evaluated. Once upgraded, frames pass through uninspected.

Configuration

Section Key Settings
proxy Port (default 8080), timeouts, rate limits, SSRF CIDR allowlist
tls CA cert/key paths, certificate cache size (default 10,000)
approval Mode: llm or passthrough, timeout (default 30s)
llm_judge Provider, model IDs, fallback mode (deny/passthrough), circuit breaker
database PostgreSQL connection URL (supports ${DATABASE_URL} expansion)
audit Output destination: stderr (default), stdout, or a file path
log_level debug, info (default), warn, error

See config/gateway.yaml.example for the full reference with inline comments.

Project Structure

crabtrap/
├── cmd/gateway/          # Entry point, admin API wiring, web UI serving
├── internal/
│   ├── proxy/            # MITM proxy, TLS cert generation, SSRF protection, rate limiting
│   ├── approval/         # Static rules engine + approval orchestration
│   ├── judge/            # LLM judge prompt construction + response parsing
│   ├── llm/              # LLM adapters, circuit breaker, concurrency control
│   ├── builder/          # Policy agent (agentic loop with tools)
│   ├── eval/             # Eval system (replay audit entries against policies)
│   ├── admin/            # Admin API routes, auth, user/audit stores
│   ├── llmpolicy/        # Policy storage and versioning
│   ├── audit/            # Structured JSON logging + event dispatch
│   ├── config/           # YAML config loading, validation, defaults
│   ├── db/               # PostgreSQL connection pool + migrations
│   └── notifications/    # SSE channel + event dispatcher
├── pkg/types/            # Shared types (StaticRule, LLMPolicy, AuditEntry, etc.)
├── web/src/              # React + TypeScript admin UI (Vite)
├── config/               # YAML configuration files
├── certs/                # Generated TLS certificates (not committed)
└── scripts/              # Certificate generation, database migrations

Observability

CrabTrap ships an optional OpenTelemetry metric surface, exposed in Prometheus scrape format on a dedicated HTTP listener. Disabled by default.

Enable it in config/gateway.yaml:

observability:
  metrics:
    enabled: true                # default: false
    listen: "127.0.0.1:9090"     # bind address (default loopback)

The metrics listener is separate from the admin and proxy ports. It serves only /metrics and requires no auth — Prometheus scrapers do not need admin credentials. Network exposure is controlled by the listen bind address: the default 127.0.0.1:9090 keeps the surface on loopback so no operator action is required to keep it private. To scrape from another host, change listen to a private interface address (e.g. 10.0.1.42:9090) and reach it from inside the trust boundary.

Minimal Prometheus scrape config (loopback bind, scraper on the same host):

scrape_configs:
  - job_name: crabtrap
    static_configs:
      - targets: ['127.0.0.1:9090']
    metrics_path: /metrics
    scrape_interval: 15s

The current metric surface includes counters for rate-limit hits and approval decisions, a gauge per LLM provider for circuit-breaker state, histograms for judge and approval latency, and a crabtrap_build_info gauge that lets operators correlate metric anomalies with deployments. See docs/observability.md for the full metric catalog, alert suggestions, and cardinality notes.

Development

make test          # lint (go vet + staticcheck) then tests with -race
make fmt           # format Go code
make lint          # go vet + staticcheck
make build         # production binary with embedded web UI
make build-web     # rebuild web UI only

See CONTRIBUTING.md for the full development workflow, PR guidelines, and coding conventions.

Releases

Releases are automated with GoReleaser via GitHub Actions. Tag a commit on main and push:

git tag v1.2.3
git push origin v1.2.3

This builds cross-platform binaries (linux/darwin, amd64/arm64), creates a GitHub Release with a changelog, and pushes multi-arch Docker images to quay.io/brexhq/crabtrap.

See CONTRIBUTING.md for release notes and commit message conventions.

License

This project is licensed under the MIT License.

Contributing

We welcome contributions! Please read CONTRIBUTING.md for guidelines on getting started, running tests, and submitting pull requests.

More