168 lines
6.6 KiB
Go
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, ", ")
|
|
}
|