claudeproxy

package
v0.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package claudeproxy relays a CI/CD pipeline's Anthropic API calls through the hub, so the pipeline can run an agent harness without ever holding the hub's Claude credential.

The shape of the bargain

A pipeline that has federated its OIDC identity (see pkg/ciauth) receives a session token and a base URL. It exports them as ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN and runs Claude Code, or any Anthropic SDK, unchanged: both send their requests to the base URL and their credential in the Authorization header, and both are satisfied by a server that answers the Messages API. Nothing in the runner knows it is not talking to Anthropic.

What it is actually talking to is a monitor. The session token is worth only what its policy says — these models, this many requests, this long — and it stops being worth anything the moment the hub closes the session. The real credential never leaves the hub process.

This is the same construction pkg/gitproxy applies to a GitHub token and pkg/kubeguard applies to a kubeconfig: take custody of the credential, give out something narrower that points at a thing that reads every request, and make the audit trail a record of decisions rather than of deliveries.

What it deliberately does not do

It does not inspect or rewrite prompts, and it does not cache responses. A proxy that reads the conversation is a proxy an operator must reason about as a data processor; this one moves bytes and decides whether it should. The exception is `max_tokens`, which is clamped because it is the one field that bounds spend, and the model name, which is checked because it is the one field that selects it.

Index

Constants

View Source
const (
	// DefaultMaxBodyBytes bounds one request. Conversations with large
	// pasted files are legitimately big; 24 MiB is past anything the
	// Messages API will accept anyway, so this is a memory guard rather
	// than a policy.
	DefaultMaxBodyBytes int64 = 24 << 20

	// DefaultMaxOutputTokens clamps max_tokens when a policy sets none.
	// Generous enough for an agent turn, finite enough that a runaway loop
	// is bounded per call.
	DefaultMaxOutputTokens = 32000
)

Default body and output bounds.

View Source
const (
	MinTTL      = 1 * time.Minute
	MaxTTL      = 6 * time.Hour
	MaxSessions = 4096
)

Session bounds. MaxSessions is a memory guard: each session is a few hundred bytes, so the bound exists to make an unbounded mint path impossible rather than because the memory matters.

View Source
const DefaultUpstream = "https://api.anthropic.com"

DefaultUpstream is the Anthropic API base. The proxy appends the path the client asked for, so this is the origin plus nothing.

View Source
const TokenPrefix = "cloop_ci_"

TokenPrefix identifies a CI session token.

The prefix is load-bearing, exactly as it is for pkg/apitoken: it is what lets a secret scanner recognise one of these in a log or a commit, and what lets the authentication path tell a session token apart from an Anthropic key a confused pipeline sent instead.

Variables

View Source
var (
	// ErrUnauthenticated covers every reason a presented token is not usable:
	// malformed, unknown, expired, closed. They are one error on purpose —
	// a caller probing for a valid session learns nothing from the
	// difference — while the audit trail records which it was.
	ErrUnauthenticated = errors.New("claudeproxy: session token not accepted")

	// ErrSessionNotFound is returned by management calls, not by
	// authentication.
	ErrSessionNotFound = errors.New("claudeproxy: no such session")

	// ErrNoUpstream means no Anthropic credential is configured, so there is
	// nothing to relay to.
	ErrNoUpstream = errors.New("claudeproxy: no upstream Anthropic credential is configured")
)

Errors the hub distinguishes.

Functions

This section is empty.

Types

type Denial

type Denial struct {
	Reason  DenyReason
	Message string
	Status  int
}

Denial is a refusal with a reason and a message safe to return to the pipeline. The message names what is wrong with the request, never what the policy would have accepted instead: a caller mapping out an allowlist by probing it should learn nothing it did not already know.

func AllowsPath

func AllowsPath(method, p string) *Denial

AllowsPath reports whether method and path are in the API surface, and returns a denial explaining which of the two failed.

Path and method are separated because they fail for different reasons and an operator reading the audit trail wants to know which: an unknown path is usually an SDK reaching for a feature the policy does not cover, while a wrong method on a known path is usually a client bug.

func (*Denial) Error

func (d *Denial) Error() string

type DenyReason

type DenyReason string

DenyReason is a stable machine-readable refusal code. Audit consumers match on these; the human-readable message beside them is free to change.

const (
	DenyPathNotAllowed  DenyReason = "path_not_allowed"
	DenyMethodNotAllowd DenyReason = "method_not_allowed"
	DenyModelNotAllowed DenyReason = "model_not_allowed"
	DenyBodyTooLarge    DenyReason = "body_too_large"
	DenyBodyMalformed   DenyReason = "body_malformed"
	DenyBudget          DenyReason = "budget_exhausted"
)

type Event

type Event struct {
	Kind      EventKind `json:"kind"`
	SessionID string    `json:"session_id,omitempty"`

	// Which rule admitted this pipeline, and which pipeline it was.
	RuleID     string `json:"rule_id,omitempty"`
	RuleName   string `json:"rule_name,omitempty"`
	Project    string `json:"project,omitempty"`
	Subject    string `json:"subject,omitempty"`
	Repository string `json:"repository,omitempty"`
	Ref        string `json:"ref,omitempty"`
	Workflow   string `json:"workflow,omitempty"`
	Actor      string `json:"actor,omitempty"`
	RunID      string `json:"run_id,omitempty"`

	// Request shape, for allowed and denied relays.
	Method string `json:"method,omitempty"`
	Path   string `json:"path,omitempty"`
	Model  string `json:"model,omitempty"`
	Status int    `json:"status,omitempty"`

	// Reason is set on a refusal.
	Reason DenyReason `json:"reason,omitempty"`

	// Spend attributable to this event.
	InputTokens  int64 `json:"input_tokens,omitempty"`
	OutputTokens int64 `json:"output_tokens,omitempty"`

	Detail string    `json:"detail,omitempty"`
	At     time.Time `json:"at"`
}

Event is one thing the proxy decided, for the audit trail.

Events carry identifiers, model names, reasons and counts. They never carry a prompt, a completion, a session token or the upstream credential: an audit log that contains the conversation is a second copy of the data the proxy was built to avoid reading.

type EventKind

type EventKind string

EventKind names what happened.

const (
	// EventExchangeAccepted / EventExchangeRejected record the OIDC
	// federation step: a pipeline presented a token and either received a
	// session or did not. Both are emitted by the hub rather than by the
	// proxy, because that exchange happens before a session exists.
	EventExchangeAccepted EventKind = "ci.exchange.accepted"
	EventExchangeRejected EventKind = "ci.exchange.rejected"

	// EventSessionMinted / EventSessionClosed bracket a session's life.
	EventSessionMinted EventKind = "ci.session.minted"
	EventSessionClosed EventKind = "ci.session.closed"

	// EventRelayAllowed is one forwarded request. It carries the token counts
	// the upstream reported, which is what makes per-pipeline spend
	// answerable.
	EventRelayAllowed EventKind = "ci.relay.allowed"

	// EventRelayDenied is one request the policy refused. The upstream never
	// saw it and the hub's credential was never attached.
	EventRelayDenied EventKind = "ci.relay.denied"

	// EventRejected is a request that never reached a session: an
	// unparseable, unknown, expired or revoked token.
	EventRejected EventKind = "ci.rejected"
)

type MessageRequest

type MessageRequest struct {
	Model     string `json:"model"`
	MaxTokens *int   `json:"max_tokens,omitempty"`
	Stream    bool   `json:"stream,omitempty"`
}

MessageRequest is the handful of fields the policy reads out of a Messages API request. Everything else is passed through untouched.

func InspectBody

func InspectBody(body []byte) (MessageRequest, error)

InspectBody decodes the policy-relevant fields of a request body.

It decodes into a struct with three fields rather than into a map, so a body carrying a 10 MB conversation is not materialised twice just to read the model name.

type MintRequest

type MintRequest struct {
	Policy Policy

	RuleID     string
	RuleName   string
	Project    string
	Subject    string
	Repository string
	Ref        string
	Workflow   string
	Actor      string
	RunID      string
	RunURL     string

	// TTL is the session lifetime, clamped to [MinTTL, MaxTTL].
	TTL time.Duration
}

MintRequest describes the session to create.

type Minted

type Minted struct {
	Session *Session

	// Token is the only copy. It is not stored, and a caller that loses it
	// must mint again.
	Token string

	// BaseURL is what the pipeline sets ANTHROPIC_BASE_URL to.
	BaseURL string
}

Minted is the one-time result of creating a session.

type Options

type Options struct {
	// Upstream is the credential and endpoint to relay to.
	Upstream Upstream

	// PathPrefix is stripped from the incoming path before the API surface
	// is matched, e.g. "/api/ci/anthropic".
	PathPrefix string

	// Transport overrides the upstream HTTP transport. Tests point this at
	// an httptest server.
	Transport http.RoundTripper

	// Now overrides the clock.
	Now func() time.Time
}

Options configures a Proxy.

type Policy

type Policy struct {
	// Models is a glob allowlist of model IDs. Empty denies everything: the
	// caller resolves "unset" to the hub default before minting.
	Models []string

	// MaxOutputTokens clamps `max_tokens` on every request. Zero uses
	// DefaultMaxOutputTokens.
	MaxOutputTokens int

	// MaxRequests caps the session's lifetime request count. Zero means
	// unlimited, which the minting path does not produce.
	MaxRequests int

	// MaxBodyBytes bounds one request body. Zero uses DefaultMaxBodyBytes.
	MaxBodyBytes int64
}

Policy is what one CI session may do.

func (Policy) AllowsModel

func (p Policy) AllowsModel(model string) bool

AllowsModel reports whether a model ID is in the allowlist.

func (Policy) BodyCap

func (p Policy) BodyCap() int64

BodyCap returns the effective request body limit.

func (Policy) DecideMessages

func (p Policy) DecideMessages(body []byte) ([]byte, MessageRequest, *Denial)

DecideMessages applies the policy to a Messages API request and returns the body to forward.

The returned body differs from the input only when max_tokens had to be clamped. Rewriting rather than refusing is deliberate: a client that asked for more output than the policy allows is not misbehaving, it is using a default, and failing the request would make every stock SDK configuration unusable behind a policy that sets any cap at all.

func (Policy) OutputCap

func (p Policy) OutputCap() int

OutputCap returns the effective max_tokens ceiling.

type Proxy

type Proxy struct {
	// contains filtered or unexported fields
}

Proxy relays requests from CI sessions to the Anthropic API.

func New

func New(reg *Registry, opts Options) (*Proxy, error)

New returns a Proxy serving sessions from reg.

func (*Proxy) Registry

func (p *Proxy) Registry() *Registry

Registry returns the registry this proxy serves.

func (*Proxy) ServeHTTP

func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type Registry

type Registry struct {

	// OnEvent receives audit events. It is called on the calling goroutine,
	// so a sink that blocks blocks a request.
	OnEvent func(Event)
	// contains filtered or unexported fields
}

Registry holds live sessions.

func NewRegistry

func NewRegistry(baseURL string) *Registry

NewRegistry returns an empty registry. baseURL is the externally reachable mount point of the proxy, handed to pipelines as ANTHROPIC_BASE_URL.

func (*Registry) Authenticate

func (r *Registry) Authenticate(token string) (*Session, error)

Authenticate resolves a presented token to a live session.

Every failure returns ErrUnauthenticated with no further detail, so a caller cannot tell an unknown session from an expired or revoked one by probing.

func (*Registry) BaseURL

func (r *Registry) BaseURL() string

BaseURL returns the proxy mount point.

func (*Registry) Close

func (r *Registry) Close(id, reason string) error

Close revokes a session. It is idempotent.

func (*Registry) CloseAll

func (r *Registry) CloseAll(reason string) int

CloseAll revokes every session, returning how many were live. Used on hub shutdown, and when an operator turns CI federation off: a policy change that leaves already-minted sessions relaying is a policy change that did not happen.

func (*Registry) CloseByRule

func (r *Registry) CloseByRule(ruleID, reason string) int

CloseByRule revokes every session minted under a rule. Called when a rule is disabled or deleted, for the same reason CloseAll exists.

func (*Registry) Mint

func (r *Registry) Mint(req MintRequest) (*Minted, error)

Mint creates a session and returns its one-time token.

func (*Registry) ReapExpired

func (r *Registry) ReapExpired() int

ReapExpired sweeps lapsed sessions and returns how many were removed. Expiry is enforced at authentication regardless, so this is hygiene.

func (*Registry) Session

func (r *Registry) Session(id string) (*Session, bool)

Session returns a session by ID.

func (*Registry) Sessions

func (r *Registry) Sessions() []*Session

Sessions lists live sessions, newest first.

func (*Registry) SetClock

func (r *Registry) SetClock(now func() time.Time)

SetClock overrides the registry clock. For tests.

type Session

type Session struct {
	ID     string
	Policy Policy

	// Provenance: which rule admitted which pipeline. Recorded at mint so an
	// audit entry describes the identity that was actually accepted, not
	// whatever the rule says today.
	RuleID     string
	RuleName   string
	Project    string
	Subject    string
	Repository string
	Ref        string
	Workflow   string
	Actor      string
	RunID      string
	RunURL     string

	IssuedAt  time.Time
	ExpiresAt time.Time
	// contains filtered or unexported fields
}

Session is one federated pipeline's authority to relay.

Counters are atomic and read without the registry lock, so a status page listing sessions cannot block a request in flight.

func (*Session) CloseReason

func (s *Session) CloseReason() string

CloseReason returns why the session was revoked, or "".

func (*Session) Closed

func (s *Session) Closed() bool

Closed reports whether the session was revoked.

func (*Session) Expired

func (s *Session) Expired(now time.Time) bool

Expired reports whether the session has lapsed.

func (*Session) Label

func (s *Session) Label() string

Label is a credential-free description for audit entries.

func (*Session) LastUsed

func (s *Session) LastUsed() time.Time

LastUsed returns the last time a request authenticated against this session, or the zero time.

func (*Session) RemainingRequests

func (s *Session) RemainingRequests() int64

RemainingRequests reports how many relayed calls the session has left, or -1 when its policy sets no cap.

func (*Session) Usage

func (s *Session) Usage() Usage

Usage snapshots the counters.

type Upstream

type Upstream struct {
	// BaseURL is the API origin. Empty uses DefaultUpstream.
	BaseURL string

	// APIKey is sent as x-api-key.
	APIKey string

	// AuthToken is sent as Authorization: Bearer.
	AuthToken string
}

Upstream is the credential the hub holds and the endpoint it relays to.

Exactly one of APIKey or AuthToken must be set. The two are different things at the API: an API key is a workspace credential presented in x-api-key, an auth token is an OAuth bearer. Guessing from the shape of the string would be a guess about billing, so the caller says which it has.

func (Upstream) Base

func (u Upstream) Base() string

Base returns the upstream origin with no trailing slash.

func (Upstream) Configured

func (u Upstream) Configured() bool

Configured reports whether there is a credential to relay with.

func (Upstream) Redacted

func (u Upstream) Redacted() string

Redacted describes the upstream without revealing the credential, for logs and for the dashboard.

func (Upstream) Validate

func (u Upstream) Validate() error

Validate checks the upstream is usable.

type Usage

type Usage struct {
	Requests     int64 `json:"requests"`
	Denied       int64 `json:"denied"`
	InputTokens  int64 `json:"input_tokens"`
	OutputTokens int64 `json:"output_tokens"`
	CacheRead    int64 `json:"cache_read_input_tokens,omitempty"`
	CacheWrite   int64 `json:"cache_creation_input_tokens,omitempty"`
	BytesUp      int64 `json:"bytes_up"`
	BytesDown    int64 `json:"bytes_down"`
}

Usage is what a session spent. Every field is cumulative for the session's lifetime.

func (Usage) TotalTokens

func (u Usage) TotalTokens() int64

TotalTokens is the sum a spend report cares about.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL