Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

168 lines
6.6 KiB
Go

package main
// Per-model scoping of the inference forwards.
//
// THE SENTENCE THIS FILE HAS TO MAKE TRUE. Someone shares one model off their
// own machine with a friend's host, and the friend must be able to say: "the
// only thing we can do with this machine is use the qwen4 model." Before this,
// the smallest thing an anchor could grant was the whole `inference` family —
// every model ollama has loaded, the OpenAI-compatible server beside it, and
// the PII sterilizer — so that sentence was false in three different ways.
//
// The scope rides in the anchor's SIGNED grant terms as `models`, beside
// `caps`. Absent or empty = UNSCOPED, which is exactly today's behaviour and
// what every grant signed before this field existed carries. A non-empty list
// is a ceiling on top of the `inference` cap: deny-by-default, like caps.
//
// Where it is enforced, and why not at accept. The forwards are raw TCP
// splices (run.go, pii.go) and a model name lives in the HTTP request BODY, so
// there is nothing to check at accept time — a connection carries no model.
// Enforcement is therefore one layer in: inferencefilter.go terminates HTTP on
// the forward when a scope is in force, checks the named model per request, and
// filters the model LISTS on the way back so the friend's host is never offered
// a model it would then be refused. agents.go applies the same filter to the
// heartbeat, for the same reason it withholds the lanes outside `inference`:
// advertising what will be refused is a lie about reachability.
//
// THE PII LANE IS NOT A MODEL. The sterilizer (pii.go) is a bearer-authed
// service, not something ollama serves, so no model name can name it and a
// scoped grant refuses it by default. The one reserved entry `pii` includes it.
// That cannot collide with a model: ollama's own names always carry a tag
// (`qwen3:4b`, `all-minilm:latest`), so a bare `pii` is never one of them.
import "strings"
// piiScopeEntry is the reserved entry in a grant's `models` list that includes
// the PII sterilization lane. Named, never implied — that is the rule for this
// grant shape: a scoped pairing gets the models it was given and nothing else.
const piiScopeEntry = "pii"
// modelScope is what one pairing may reach on the inference lanes of THIS box.
//
// scoped=false the grant named no models: every loaded model, and the PII
// lane if the box runs one. The contract from before `models`
// existed, unchanged.
// scoped=true only `models`, and PII only if `pii` was named.
type modelScope struct {
scoped bool
models []string
pii bool
}
// unscopedModels is the ceiling-free scope: what the anchor pairing holds, what
// an unanchored local box hands its one host, and what every grant signed
// before `models` existed means.
func unscopedModels() modelScope { return modelScope{} }
// pairingModelScope is the scope for one pairing, read from its VERIFIED grant
// on every call (like capsAllow — a re-approval that narrows the list must take
// effect without a restart, and a tampered grant file must authorise nothing).
//
// Fails CLOSED in one direction only: a pairing with no verifiable grant is
// already refused the whole `inference` family by capsAllow, so the scope it
// gets here never decides anything. Returning unscoped for it would be a lie
// waiting for a caller that forgot the caps check, so it returns an EMPTY
// scoped list — the narrowest possible answer.
func pairingModelScope(self string) modelScope {
a := currentAnchor()
if a == nil {
// Unanchored: a local box gates nothing (the pairing IS the
// authorization there), and an unanchored networked box never reaches
// a forward at all — unanchoredNetworkedRefusal closes it in capsAllow.
return unscopedModels()
}
if self == "" {
return modelScope{scoped: true}
}
if a.AnchorHost == self {
return unscopedModels() // the anchor user is the machine's admin
}
t, ok := pairingGrantTerms(self)
if !ok {
return modelScope{scoped: true}
}
return scopeFromTerms(t.Models)
}
// scopeFromTerms turns the signed `models` list into a scope. Kept separate
// from the grant read so the signed-bytes → policy step is testable on its own.
// A PRESENT list is a scope, even if nothing in it survives trimming. The first
// cut set `scoped` only when it kept an entry, so a grant carrying ["", " "]
// came out UNSCOPED — a malformed ceiling failing open into no ceiling at all.
// The anchor host signs these, so it was never likely; it was still the wrong
// direction to fail in.
func scopeFromTerms(models []string) modelScope {
s := modelScope{scoped: len(models) > 0}
for _, m := range models {
m = strings.ToLower(strings.TrimSpace(m))
if m == "" {
continue
}
if m == piiScopeEntry {
s.pii = true
continue
}
s.models = append(s.models, m)
}
return s
}
// allowsPII answers the one question the PII forward asks.
func (s modelScope) allowsPII() bool { return !s.scoped || s.pii }
// allowsModel matches a model name from a request or a model list against the
// scope. Matching is EXACT, with one equivalence: ollama's default tag, so a
// grant naming `all-minilm` admits `all-minilm:latest` and the reverse. A bare
// repo name never admits another tag — `qwen3` does not open `qwen3:70b`, which
// would be a silent widening of what the user read when they approved.
func (s modelScope) allowsModel(name string) bool {
if !s.scoped {
return true
}
want := strings.ToLower(strings.TrimSpace(name))
if want == "" {
return false
}
for _, m := range s.models {
if m == want || defaultTagEq(m, want) {
return true
}
}
return false
}
// defaultTagEq: "x" and "x:latest" are the same model to ollama.
func defaultTagEq(a, b string) bool {
return a+":latest" == b || b+":latest" == a
}
// filterModels keeps only the names this scope allows, preserving order. Used
// on every list that leaves this box for a scoped pairing — the heartbeat's
// model lists (agents.go) and the /api/tags, /v1/models responses the forward
// proxies back (inferencefilter.go).
func (s modelScope) filterModels(names []string) []string {
if !s.scoped {
return names
}
out := []string{}
for _, n := range names {
if s.allowsModel(n) {
out = append(out, n)
}
}
return out
}
// describe is the refusal sentence: what this pairing may actually use. Named
// in the 403 the filter returns, so the friend's host is told the truth rather
// than left to guess at a model that "does not exist".
func (s modelScope) describe() string {
if !s.scoped {
return "every model on this machine"
}
if len(s.models) == 0 {
return "no models"
}
return strings.Join(s.models, ", ")
}