CrabTrap: a Go proxy for putting policy in front of AI agents
AI agents are starting to get real credentials. GitHub tokens, Slack tokens, Stripe keys, Gmail access, internal API keys. Once an agent can make outbound HTTP calls, a bad tool call stops being a weird chat transcript and starts being a production change.
CrabTrap tackles that problem as an outbound HTTP/HTTPS proxy. Agents send traffic through it. CrabTrap authenticates the caller, applies deterministic rules, optionally asks an LLM judge to evaluate the request against a policy, forwards approved requests, blocks denied ones, and writes an audit trail to PostgreSQL.
The implementation is worth reading because it is plain Go in a difficult part of the stack: proxying, TLS interception, request body handling, and security decision plumbing.
The shape of the proxy
CrabTrap exposes a normal net/http server with a custom handler:
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", s.config.Proxy.Port),
Handler: handler,
ReadTimeout: s.config.Proxy.ReadTimeout,
WriteTimeout: s.config.Proxy.WriteTimeout,
IdleTimeout: s.config.Proxy.IdleTimeout,
}
The handler owns the pieces you would expect in a forward proxy: TLS certificate generation for intercepted HTTPS, an upstream HTTP client, user resolution, approval checks, audit logging, rate limiting, and SSRF protection.
type Handler struct {
tlsManager *TLSManager
approvalManager *approval.Manager
auditLogger *audit.Logger
auditReader admin.AuditReaderIface
userResolver admin.UserResolver
client *http.Client
// ...
}
That matters because a security proxy is more than “send request upstream, copy response back”. CrabTrap has to make a decision before forwarding, and that decision depends on the authenticated user, the request URL, headers, body, assigned policy, static rules, and LLM availability.
Approval is a separate package
The cleanest boundary is internal/approval. The proxy handler gathers request context, then calls the approval manager:
decision, body, approvalErr = h.approvalManager.CheckApproval(ctx, r, requestID, nil)
The manager has two modes:
type Manager struct {
judge *judge.LLMJudge
mode string // "llm" | "passthrough"
fallbackMode string // "deny" | "passthrough"
}
In passthrough mode it approves and records the request. In LLM mode it runs the real decision pipeline.
The static-rule path happens before any model call:
if policy != nil && len(policy.StaticRules) > 0 {
var hasAllow, hasDeny bool
for _, rule := range policy.StaticRules {
if staticRuleMatches(rule, req) {
if rule.Action == "deny" {
hasDeny = true
} else {
hasAllow = true
}
}
}
if hasDeny {
return types.ApprovalDecision{Decision: types.DecisionDeny}, body, nil
}
if hasAllow {
return types.ApprovalDecision{Decision: types.DecisionAllow}, body, nil
}
}
Deny wins over allow. That is a good default for policy engines, and the code keeps it obvious. Static rules also save latency and cost; if a URL/method rule can decide the request, the LLM does not need to run.
Static URL matching is deliberately boring
The rule matcher supports method filters and URL match types: prefix, exact, and glob. Glob rules compile into regexps and use a bounded cache:
const globRegexpCacheMaxSize = 1024
var globCache = struct {
sync.RWMutex
m map[string]*regexp.Regexp
}{m: make(map[string]*regexp.Regexp)}
The cache strategy is intentionally simple: when the cap is hit, clear the whole map. Policies are admin-authored and small, so a full LRU would add complexity without much benefit.
There is also a small but useful normalization step: default ports are stripped before URL comparison, so https://example.com:443/path and https://example.com/path behave the same. Those are the kinds of edge cases that make policy systems feel predictable.
The LLM judge gets structured input
The judge lives in internal/judge. It does not concatenate a giant free-form prompt with raw request text. It builds a system prompt from the assigned policy and sends the request as JSON.
The system prompt JSON-escapes the policy string:
func buildSystemPrompt(policyPrompt string) string {
policyJSON, _ := json.Marshal(policyPrompt)
return `You are a security policy enforcement agent...
The policy to enforce is provided below as a JSON-encoded string. Parse the string value to read the policy:
{"policy":` + string(policyJSON) + `}
Respond ONLY with valid JSON...`
}
The request sent to the model is also represented as JSON:
type requestJSON struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body string `json:"body,omitempty"`
Warnings []string `json:"warnings,omitempty"`
MultipartSummary string `json:"multipart_summary,omitempty"`
}
That is a practical prompt-injection defense. User-controlled URLs, headers, and bodies are encoded as data rather than spliced into the instruction text. It does not make the judge perfect, but it removes a whole class of sloppy prompt construction bugs.
The judge expects a tiny response schema:
type decisionJSON struct {
Decision string `json:"decision"`
Reason string `json:"reason"`
}
Then it uppercases and validates the decision against ALLOW or DENY. Unknown output becomes an error, and the approval manager falls back according to config.
Truncation is part of the security model
LLM context is finite, and attackers can exploit that. CrabTrap caps URL, header, and body material before sending it to the judge:
const maxBodyBytes = 16384
const maxURLBytes = 2048
const maxHeaderBytes = 4096
const maxHeaderValueBytes = 512
Security-relevant headers are emitted first:
var securityHeaders = []string{
"Host",
"Content-Type",
"Content-Encoding",
"Transfer-Encoding",
"Authorization",
"Content-Length",
"Origin",
"Referer",
"X-Forwarded-For",
"X-Forwarded-Host",
"Cookie",
}
That prevents a pile of junk headers from pushing Authorization, Cookie, or Content-Encoding out of the model input. When content is truncated, the JSON includes warnings so the policy judge can take the missing context into account.
Multipart bodies get special handling. If the body is truncated, CrabTrap tries to summarize the parts: field names, filenames, content types, sizes, and previews for textual parts. That is much better than showing the first few kilobytes of a file upload and pretending the rest does not exist.
Request bodies need careful handling
A proxy that inspects a request body still has to forward that body upstream if the request is approved. In Go, req.Body is a stream. Read it once and it is gone unless you put it back.
CrabTrap uses context values to avoid accidental double reads:
const ContextKeyBufferedBody contextKey = "buffered_body"
const ContextKeyOriginalHeaders contextKey = "original_headers"
const ContextKeyOriginalBody contextKey = "original_body"
When the proxy handler has already buffered a request prefix, the approval manager uses that copy rather than reading req.Body again:
func requestBodyForApproval(ctx context.Context, req *http.Request) ([]byte, error) {
if body, ok := ctx.Value(ContextKeyBufferedBody).([]byte); ok {
return body, nil
}
var body []byte
if req.Body != nil {
var err error
body, err = io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
req.Body.Close()
}
req.Body = io.NopCloser(bytes.NewReader(body))
return body, nil
}
Large uploads are handled with a cap. The proxy reads up to its maximum buffered body size plus one byte. If the body is larger, it reconstructs the request body with io.MultiReader, so the buffered prefix can be inspected and the unread tail can still stream to the upstream server after approval.
That is the right shape for this kind of tool. Buffer enough to make a decision; avoid turning every upload into an unbounded memory allocation.
Compressed bodies are evaluated as plaintext when possible
If the request has Content-Encoding, CrabTrap tries to decompress the body before the LLM sees it. The upstream request still receives the original compressed body.
The comments in handler.go make the intent clear: evaluate plaintext, forward unchanged bytes. When decompression succeeds, the evaluation headers remove Content-Encoding and Content-Length so the judge does not see a decompressed body with headers describing the compressed one.
Unsupported or failed encodings keep the original body and the original encoding header. That lets the judge see that the request body is encoded content it could not inspect.
This is a good example of a small proxy detail that matters. If a policy judge only sees gzipped bytes, the policy is mostly theatre.
SSRF protection is built into dialing
CrabTrap also guards the upstream connection path. The proxy blocks private, loopback, link-local, carrier-grade NAT, IPv6 ULA, NAT64, and 6to4 ranges unless the deployment explicitly allows CIDRs.
The dialer resolves the host, checks every resolved IP, and only then dials:
func safeDialContext(ctx context.Context, network, addr string, allowedCIDRs []*net.IPNet) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
// resolve host
// reject blocked IPs
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
The important part is that resolution and connection are tied together. The code does not check one hostname and then let the default transport resolve it again later. That is how DNS rebinding gaps creep in.
Failure policy is explicit
If the judge is unavailable, CrabTrap uses a configured fallback:
func (m *Manager) llmFallback(...) (types.ApprovalDecision, []byte, error) {
if m.fallbackMode == "passthrough" {
return types.ApprovalDecision{Decision: types.DecisionAllow}, body, nil
}
return types.ApprovalDecision{Decision: types.DecisionDeny}, body, nil
}
The default config uses deny. Passthrough exists for deployments that prefer availability over enforcement, but the warning log is loud for a reason.
The LLM adapter layer also has resilience controls: max concurrency, circuit breaker threshold, and cooldown. That belongs below the approval manager. The manager should ask for a decision; the adapter should deal with model transport behaviour.
What I would take from CrabTrap
The useful Go lesson is the shape of the pipeline.
Keep proxying in one package. Keep approval orchestration in another. Make static rules cheap and deterministic. Encode model inputs as structured data. Preserve the original request body for forwarding. Put explicit caps around everything that can be inflated by an attacker. Treat compressed bodies as content that needs inspection, not opaque bytes. Tie DNS resolution to dialing when SSRF matters. Make fallback behaviour a configuration choice people have to own.
CrabTrap is not a small toy example. It has a web UI, PostgreSQL-backed audit logs, policy versioning, eval replay, TLS interception, WebSocket handling, LLM adapters, and a lot of tests. The parts above are the ones I would study first if I were building outbound controls for agent traffic in Go.