claudeproxy

package
v0.0.6 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 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.

View Source
var (
	// ErrSessionExists: the registry already holds a session with that id.
	ErrSessionExists = errors.New("claudeproxy: a session with that id is already live")
	// ErrSessionLapsed: the session's TTL ran out before it could be restored.
	ErrSessionLapsed = errors.New("claudeproxy: session lapsed before it could be restored")
	// ErrRecordGone is what a Store answers a checkpoint whose record is no
	// longer this process's to write: closed by another hub process — its
	// rule was withdrawn, it was revoked — or taken over by one. The
	// registry stops serving the session: the record, not this process's
	// memory, says whether a session stands.
	ErrRecordGone = errors.New("claudeproxy: the session's record is closed or held by another hub process")
	// ErrClosedHere: this registry closed the session — revoked, expired,
	// its rule withdrawn — and does not bring back a session it ended, even
	// when the write of that end to the record failed.
	ErrClosedHere = errors.New("claudeproxy: this hub process closed the session")
	// ErrTooManySessions: MaxSessions are live. It says nothing about the
	// session asked for, which may be minted or restored once one ends.
	ErrTooManySessions = errors.New("claudeproxy: the session limit is reached")
)

Errors Restore distinguishes.

Functions

func IntersectModels added in v0.0.5

func IntersectModels(a, b []string) []string

IntersectModels returns model patterns admitting only models both a and b admit, sorted. See Narrow for why it may drop a model both admit.

func SessionIDOf added in v0.0.5

func SessionIDOf(token string) (string, bool)

SessionIDOf returns the id half of a session token without checking the secret half, and whether the token has the shape of one.

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"

	// EventSessionSuspended: a hub process stopping gracefully stopped
	// serving a recorded session without ending it, so the process that
	// receives the pipeline's next request can restore it (Task 20390).
	EventSessionSuspended EventKind = "ci.session.suspended"

	// EventSessionRestored: a hub process took over the record of a session
	// whose holder had stopped, held it to its rule as the rule stands now,
	// and serves it under its original id and token.
	EventSessionRestored EventKind = "ci.session.restored"

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

	// Claims is the verified assertion the pipeline federated with, recorded
	// with the session for the hub that restores it (Task 20390). Opaque
	// here, never read, never retained in memory past the record's write.
	Claims json.RawMessage
}

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

	// Fallback, when set, is offered every request whose credential names a
	// session this registry has never heard of, before it is refused
	// (Task 20354). Several hub processes may serve one control plane, each
	// with its own registry, and a sandbox's request reaches whichever of
	// them the network sends it to; the hub forwards it to the process that
	// minted the session. It reports whether it answered the request.
	// Credentials naming a session this registry knows — right or wrong
	// token, live or expired — are always decided here.
	Fallback func(w http.ResponseWriter, r *http.Request, sessionID string) bool
}

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 `json:"models"`

	// MaxOutputTokens clamps `max_tokens` on every request. Zero uses
	// DefaultMaxOutputTokens.
	MaxOutputTokens int `json:"max_output_tokens,omitempty"`

	// MaxRequests caps the session's lifetime request count. Zero means
	// unlimited, which the minting path does not produce.
	MaxRequests int `json:"max_requests,omitempty"`

	// MaxBodyBytes bounds one request body. Zero uses DefaultMaxBodyBytes.
	MaxBodyBytes int64 `json:"max_body_bytes,omitempty"`
}

Policy is what one CI session may do.

func Narrow added in v0.0.5

func Narrow(recorded, now Policy) (Policy, error)

Narrow holds a recorded policy to the one its rule grants now: it returns a policy no wider than either. A model is permitted only when both allowlists permit it, and every cap is the lower of the two. It refuses when no model the session was minted for is permitted any more — a session that could authenticate and do nothing.

The model intersection is computed on the patterns, conservatively: a pair of patterns whose common models cannot be proved to be one of them contributes nothing, so the result can be narrower than the exact intersection but never wider.

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)

	// Store, if set, records every session and keeps the record in step
	// (durable.go), so another hub process can restore it. Nil keeps
	// sessions in memory only, as every registry did before Task 20390.
	Store SessionStore
	// 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)

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) CheckRestore added in v0.0.5

func (r *Registry) CheckRestore(rec SessionRecord) error

CheckRestore reports why Restore would refuse rec, or nil.

func (*Registry) Checkpoint added in v0.0.5

func (r *Registry) Checkpoint() int

Checkpoint writes to the Store what every durable session has spent since its last checkpoint, and returns how many it wrote. The hub calls it on a timer — so a process that dies loses at most one interval's counts — and when it stops. A session whose counters have not moved is skipped, and one whose write fails is tried again next time.

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) DropUnheld added in v0.0.5

func (r *Registry) DropUnheld(ids []string, held map[string]bool) int

DropUnheld stops serving each session in ids whose record is not among held — the open records this process holds, read after ids were — and returns how many it dropped. A session whose record was closed elsewhere stops at once, whether or not its counters moved since the last checkpoint.

func (*Registry) DurableIDs added in v0.0.5

func (r *Registry) DurableIDs() []string

DurableIDs returns the ids of the recorded sessions this registry serves. With DropUnheld it is how the hub reconciles the registry with the records it holds: read the ids first, then the records, so a session minted between the two is not mistaken for one whose record went elsewhere.

func (*Registry) Known added in v0.0.5

func (r *Registry) Known(id string) bool

Authenticate resolves a presented token to a live session.

Every failure returns ErrUnauthenticated with no further detail, so a caller Known reports whether id names a session this registry holds, live or not. See Options.Fallback.

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) Restore added in v0.0.5

func (r *Registry) Restore(req RestoreRequest) (*Session, error)

Restore brings back a session another hub process minted, under the same id and token hash, with the counters it had reached. Its end is recorded in the Store like a minted session's.

It refuses a record it could not have minted — an id or a token hash of the wrong shape, a policy permitting no model, a deadline beyond MaxTTL from its issue — and a session already past its deadline or already live here: a restored session is checked as strictly as a new one, because the record it comes from was read back from a database rather than produced here.

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.

func (*Registry) Suspend added in v0.0.5

func (r *Registry) Suspend(id, reason string) bool

Suspend stops serving a session without ending it: what it spent is written to its Store, it leaves the registry, and a request already authenticated against it finishes while any later one is refused here — but its record stays open, so the hub process that receives its next request can restore it. A session the Store holds no record of is closed instead, with reason: nothing could restore it. It reports whether the session was suspended.

func (*Registry) SuspendAll added in v0.0.5

func (r *Registry) SuspendAll(reason string) []string

SuspendAll suspends every session, as a hub stopping gracefully does, and returns the ids of the durable ones it suspended, sorted. Sessions with no record are closed with reason.

type RestoreRequest added in v0.0.5

type RestoreRequest struct {
	// Record is the session as its Store recorded it, with the policy and
	// deadline the caller holds it to now — never wider than recorded.
	Record SessionRecord
	// From names the hub process the session was taken over from, for the
	// restored row.
	From string
	// Detail, when set, is added to the restored row: what the caller
	// narrowed.
	Detail string
}

RestoreRequest is a session to bring back.

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) Durable added in v0.0.5

func (s *Session) Durable() bool

Durable reports whether a Store holds a record of the session.

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 SessionRecord added in v0.0.5

type SessionRecord struct {
	ID string
	// TokenSHA256 is the hex SHA-256 of the session token.
	TokenSHA256 string
	Policy      Policy

	RuleID     string
	RuleName   string
	Project    string
	Subject    string
	Repository string
	Ref        string
	Workflow   string
	Actor      string
	RunID      string
	RunURL     string
	// Claims is the verified assertion the session was minted for, as the
	// hub chose to record it: opaque to this package, which never reads it.
	// The hub holds a restored session to its rule against it.
	Claims json.RawMessage

	IssuedAt  time.Time
	ExpiresAt time.Time

	// Usage and LastUsed are what the session had spent when it was last
	// checkpointed.
	Usage    Usage
	LastUsed time.Time
}

SessionRecord is what a Store keeps of a session: everything Restore needs. It has no field a token or an upstream credential could travel in.

type SessionStore added in v0.0.5

type SessionStore interface {
	// SaveSession records a session this process now serves.
	SaveSession(rec SessionRecord) error
	// CheckpointSession records what a session this process serves has
	// spent so far.
	CheckpointSession(id string, u Usage, lastUsed time.Time) error
	// CloseSession records that a session this process served has ended,
	// and why, with what it spent.
	CloseSession(id, reason string, at time.Time, u Usage) error
}

SessionStore persists the record of every session and keeps it in step.

Errors are the implementation's to report: the registry cannot do anything useful with one beyond what it does — a session whose record could not be written is served all the same, and its minted row says what was lost.

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