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
- Variables
- type Denial
- type DenyReason
- type Event
- type EventKind
- type MessageRequest
- type MintRequest
- type Minted
- type Options
- type Policy
- type Proxy
- type Registry
- func (r *Registry) Authenticate(token string) (*Session, error)
- func (r *Registry) BaseURL() string
- func (r *Registry) Close(id, reason string) error
- func (r *Registry) CloseAll(reason string) int
- func (r *Registry) CloseByRule(ruleID, reason string) int
- func (r *Registry) Mint(req MintRequest) (*Minted, error)
- func (r *Registry) ReapExpired() int
- func (r *Registry) Session(id string) (*Session, bool)
- func (r *Registry) Sessions() []*Session
- func (r *Registry) SetClock(now func() time.Time)
- type Session
- type Upstream
- type Usage
Constants ¶
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.
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.
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.
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 ¶
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 ¶
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.
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 ¶
AllowsModel reports whether a model ID is in the allowlist.
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.
type Proxy ¶
type Proxy struct {
// contains filtered or unexported fields
}
Proxy relays requests from CI sessions to the Anthropic API.
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 ¶
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 ¶
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) CloseAll ¶
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 ¶
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 ¶
ReapExpired sweeps lapsed sessions and returns how many were removed. Expiry is enforced at authentication regardless, so this is hygiene.
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 ¶
CloseReason returns why the session was revoked, or "".
func (*Session) LastUsed ¶
LastUsed returns the last time a request authenticated against this session, or the zero time.
func (*Session) RemainingRequests ¶
RemainingRequests reports how many relayed calls the session has left, or -1 when its policy sets no cap.
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) Configured ¶
Configured reports whether there is a credential to relay with.
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 ¶
TotalTokens is the sum a spend report cares about.