Files
Privatae 4012da0b75
ci / personal-data (push) Successful in 10s
ci / test (push) Successful in 5m30s
ci / fuzz (push) Successful in 3m16s
bastion 0.56.0
2026-10-28 02:24:07 +00:00

914 lines
36 KiB
Go

//go:build !windows
package main
// Sharing ONE model off this machine with another host.
//
// The sentence under test throughout: a pairing approved with caps
// ["inference"] and models ["qwen4-embed"] can use qwen4-embed here and NOTHING
// else — not another model, not the PII sterilizer, and not the half of
// ollama's API that changes what is on the user's disk.
import (
"bufio"
"bytes"
"crypto/ed25519"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"sync"
"testing"
"time"
)
// grantTermsModels is grantTerms with the `models` field. Separate so every
// existing test keeps signing terms with NO models key at all — which is the
// case that has to keep meaning "unscoped".
func grantTermsModels(subject string, caps, models []string, expires string) map[string]any {
t := grantTerms(subject, caps, expires)
t["models"] = models
return t
}
// syncGrant approves `subject` on the box with caps+models, through the real
// mgmt frame path as the anchor.
func syncGrant(t *testing.T, priv ed25519.PrivateKey, subject string, caps, models []string) {
t.Helper()
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
var terms, sig string
if models == nil {
terms, sig = signTerms(t, priv, grantTerms(subject, caps, future))
} else {
terms, sig = signTerms(t, priv, grantTermsModels(subject, caps, models, future))
}
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("grant_sync: %d %s", code, errOut)
}
}
// ── the scope itself ───────────────────────────────────────────────────────
func TestModelScopeReadsTheSignedGrant(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
// No models key: UNSCOPED. Every grant signed before the field existed is this case,
// and it must keep meaning exactly what it meant.
syncGrant(t, priv, tMember, []string{"inference"}, nil)
if s := pairingModelScope(tMember); s.scoped {
t.Fatal("a grant with no models list must be unscoped")
}
if !pairingModelScope(tMember).allowsModel("anything:latest") {
t.Fatal("an unscoped pairing may use any model")
}
if !pairingModelScope(tMember).allowsPII() {
t.Fatal("an unscoped pairing keeps the PII lane it had before")
}
// Named models: scoped, and the named one only.
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
s := pairingModelScope(tMember)
if !s.scoped {
t.Fatal("a grant naming models must be scoped")
}
if !s.allowsModel("qwen4-embed") {
t.Fatal("the named model must be allowed")
}
if !s.allowsModel("qwen4-embed:latest") {
t.Fatal("ollama's default tag is the same model")
}
if s.allowsModel("llama3:70b") {
t.Fatal("a model outside the list must be refused")
}
if s.allowsModel("qwen4-embed:q8") {
t.Fatal("a bare repo name must NOT open another tag — that is a silent widening")
}
if s.allowsPII() {
t.Fatal("the sterilizer is not a model; a scoped grant must not include it unnamed")
}
// The reserved entry, and it is not a model.
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed", "pii"})
s = pairingModelScope(tMember)
if !s.allowsPII() {
t.Fatal("'pii' in the list must include the sterilizer")
}
if s.allowsModel("pii") {
t.Fatal("'pii' must name the lane, never a model")
}
// The anchor user is the machine's admin: never scoped.
if pairingModelScope(tAnchor).scoped {
t.Fatal("the anchor pairing must stay unscoped")
}
}
// A pairing with no verifiable grant holds no `inference` at all (capsAllow
// refuses it), so the scope it gets never decides anything — but it must still
// be the NARROWEST answer, not the widest, in case a caller ever forgets the
// caps check.
func TestUngrantedPairingScopeFailsClosed(t *testing.T) {
setupBox(t)
writePairingState(t, tMember, pairingPending)
s := pairingModelScope(tMember)
if !s.scoped || s.allowsModel("anything") || s.allowsPII() {
t.Fatalf("a pairing with no grant must get an empty scope, got %+v", s)
}
if !pairingModelScope("").scoped {
t.Fatal("an anonymous lane must get an empty scope")
}
}
// ── the policy, as a table ─────────────────────────────────────────────────
func TestFilterVerdictPolicy(t *testing.T) {
scoped := scopeFromTerms([]string{"qwen4-embed"})
unscoped := unscopedModels()
embedBody := []byte(`{"model":"qwen4-embed","input":"hello"}`)
otherBody := []byte(`{"model":"llama3:70b","input":"hello"}`)
cases := []struct {
name string
path string
body []byte
lane inferenceLane
scope modelScope
mayManage bool
wantRefused bool
wantMentioning string
}{
// The store-mutating half of ollama's API is the `models` family's, and
// a raw splice served it to anything holding `inference`.
{"pull refused without the models cap", "/api/pull", nil, laneOllama, unscoped, false, true, "models"},
{"delete refused without the models cap", "/api/delete", nil, laneOllama, unscoped, false, true, "models"},
{"create refused without the models cap", "/api/create", nil, laneOllama, unscoped, false, true, "models"},
{"blob upload refused without the models cap", "/api/blobs/sha256:ab", nil, laneOllama, unscoped, false, true, "models"},
{"pull allowed with the models cap", "/api/pull", nil, laneOllama, unscoped, true, false, ""},
// An unscoped pairing is otherwise unchanged.
{"unscoped generate passes", "/api/generate", otherBody, laneOllama, unscoped, false, false, ""},
{"unscoped unknown path passes", "/api/whatever", nil, laneOllama, unscoped, false, false, ""},
// Scoped: the model decides.
{"scoped embed on the named model", "/api/embed", embedBody, laneOllama, scoped, false, false, ""},
{"scoped embed on another model", "/api/embed", otherBody, laneOllama, scoped, false, true, "llama3:70b"},
{"scoped chat on another model", "/v1/chat/completions", otherBody, laneLLM, scoped, false, true, "llama3:70b"},
{"scoped request naming no model", "/api/embed", []byte(`{"input":"x"}`), laneOllama, scoped, false, true, "name the model"},
{"scoped show by the older `name` key", "/api/show", []byte(`{"name":"llama3:70b"}`), laneOllama, scoped, false, true, "llama3:70b"},
// Scoped reads that carry no model, and deny-by-default for the rest.
{"scoped tags list is served (and filtered later)", "/api/tags", nil, laneOllama, scoped, false, false, ""},
{"scoped version is harmless", "/api/version", nil, laneOllama, scoped, false, false, ""},
{"scoped unknown path is refused", "/api/whatever", nil, laneOllama, scoped, false, true, "not served"},
// The llm lane has no store-mutating surface of its own.
{"llm lane does not police /api/pull", "/api/pull", nil, laneLLM, unscoped, false, false, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
why, code := filterVerdict(c.path, c.body, c.lane, c.scope, c.mayManage)
if c.wantRefused != (why != "") {
t.Fatalf("refused=%v, want %v (why=%q)", why != "", c.wantRefused, why)
}
if !c.wantRefused {
return
}
if code != http.StatusForbidden {
t.Fatalf("status %d, want 403", code)
}
if !strings.Contains(why, c.wantMentioning) {
t.Fatalf("refusal %q does not mention %q — the caller cannot act on it", why, c.wantMentioning)
}
})
}
}
// ── the filter on a real connection ────────────────────────────────────────
// fakeOllama answers the handful of routes the filter cares about and records
// what actually reached it — the point of most of these assertions is that a
// refused request never arrives at all. Its handler runs on the server's
// goroutines, beside the test's (and a pairing's beat probes it on its own),
// so the record is read through requests(), under the lock.
type fakeOllama struct {
srv *httptest.Server
mu sync.Mutex
seen []string
}
// requests is a copy of what reached the fake so far.
func (f *fakeOllama) requests() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.seen)
}
func newFakeOllama(t *testing.T) *fakeOllama {
t.Helper()
f := &fakeOllama{}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.seen = append(f.seen, r.Method+" "+r.URL.Path)
f.mu.Unlock()
switch r.URL.Path {
case "/api/tags":
_, _ = w.Write([]byte(`{"models":[{"name":"qwen4-embed:latest","size":42},` +
`{"name":"llama3:70b","size":9}]}`))
case "/v1/models":
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"qwen4-embed"},{"id":"llama3:70b"}]}`))
default:
_, _ = w.Write([]byte(`{"ok":true}`))
}
}))
t.Cleanup(f.srv.Close)
return f
}
// roundTrip drives one request through serveInferenceFiltered and returns the
// response. The filter is given one side of a pipe, exactly as the forward
// gives it an accepted tunnel connection — and it reads the pairing's scope out
// of the signed grant on disk, so every case here goes through the real
// terms -> verify -> scope path rather than a scope handed in by the test.
func roundTrip(t *testing.T, upstream string, lane inferenceLane, self string,
method, path, body string) *http.Response {
t.Helper()
client, server := net.Pipe()
go serveInferenceFiltered(server, upstream, lane, self)
req, err := http.NewRequest(method, "http://filter"+path, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
_ = client.SetDeadline(time.Now().Add(10 * time.Second))
errc := make(chan error, 1)
go func() { errc <- req.Write(client) }()
resp, err := http.ReadResponse(bufio.NewReader(client), req)
if err != nil {
t.Fatalf("read response: %v", err)
}
if err := <-errc; err != nil {
t.Fatalf("write request: %v", err)
}
t.Cleanup(func() { resp.Body.Close(); client.Close() })
return resp
}
func bodyOf(t *testing.T, resp *http.Response) string {
t.Helper()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestFilteredForwardRefusesAnotherModelAndNeverForwardsIt(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
up := newFakeOllama(t)
addr := up.srv.Listener.Addr().String()
resp := roundTrip(t, addr, laneOllama, tMember, "POST", "/api/embed",
`{"model":"llama3:70b","input":"x"}`)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status %d, want 403", resp.StatusCode)
}
if b := bodyOf(t, resp); !strings.Contains(b, "llama3:70b") || !strings.Contains(b, "qwen4-embed") {
t.Fatalf("the refusal must name what was asked for and what is shared: %s", b)
}
if len(up.requests()) != 0 {
t.Fatalf("a refused request reached the upstream: %v", up.requests())
}
resp = roundTrip(t, addr, laneOllama, tMember, "POST", "/api/embed",
`{"model":"qwen4-embed","input":"x"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("the shared model must work: %d", resp.StatusCode)
}
if len(up.requests()) != 1 || up.requests()[0] != "POST /api/embed" {
t.Fatalf("the allowed request must reach the upstream once: %v", up.requests())
}
}
func TestFilteredForwardRefusesTheStoreMutatingHalf(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, nil) // inference, NOT models
up := newFakeOllama(t)
addr := up.srv.Listener.Addr().String()
if !inferenceFilterNeeded(tMember, laneOllama, pairingModelScope(tMember)) {
t.Fatal("a pairing without the models cap must be filtered on the ollama lane — " +
"a raw splice serves /api/pull and /api/delete to it")
}
for _, p := range []string{"/api/pull", "/api/delete", "/api/create", "/api/copy", "/api/push"} {
resp := roundTrip(t, addr, laneOllama, tMember, "POST", p, `{"model":"anything"}`)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s: status %d, want 403", p, resp.StatusCode)
}
}
if len(up.requests()) != 0 {
t.Fatalf("a store-mutating request reached ollama: %v", up.requests())
}
// ...and using a model is untouched by that refusal.
if resp := roundTrip(t, addr, laneOllama, tMember, "POST", "/api/embed",
`{"model":"anything"}`); resp.StatusCode != http.StatusOK {
t.Fatalf("an unscoped pairing must still USE models: %d", resp.StatusCode)
}
}
// On an ANCHORED box the ollama lane is filtered for every pairing — the
// anchor and a pairing holding `models` included — because changing which
// models are on a fleet machine is the gated model:pull / model:drop, and a
// raw splice that served /api/pull was a way around that (0.33.0). The llm
// lane has no store-mutating surface and stays a raw splice for an unscoped
// pairing; a LOCAL box keeps the raw ollama splice a `models` holder had.
func TestPairingWithModelsCapKeepsTheRawSplice(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference", "models"}, nil)
if !inferenceFilterNeeded(tMember, laneOllama, pairingModelScope(tMember)) {
t.Fatal("on an anchored box the ollama lane is filtered even for a models holder")
}
if !inferenceFilterNeeded(tAnchor, laneOllama, pairingModelScope(tAnchor)) {
t.Fatal("on an anchored box the anchor's ollama lane is filtered too")
}
if inferenceFilterNeeded(tMember, laneLLM, unscopedModels()) {
t.Fatal("the llm lane must not be filtered for an unscoped pairing")
}
// A local box: the pairing is the authorization, the raw splice stays.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
writePairing(t, tAnchor)
if inferenceFilterNeeded(tAnchor, laneOllama, pairingModelScope(tAnchor)) {
t.Fatal("a local box keeps the unfiltered path")
}
}
// And the filtered path refuses the store-mutating half for the ANCHOR on an
// anchored box: /api/pull through the forward is not a way around model:pull.
func TestAnchoredBoxRefusesModelChangesThroughTheForwardForTheAnchor(t *testing.T) {
setupBox(t)
up := newFakeOllama(t)
addr := up.srv.Listener.Addr().String()
for _, p := range []string{"/api/pull", "/api/delete", "/api/create"} {
resp := roundTrip(t, addr, laneOllama, tAnchor, "POST", p, `{"model":"anything"}`)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s for the anchor: status %d, want 403", p, resp.StatusCode)
}
}
if len(up.requests()) != 0 {
t.Fatalf("a store-mutating request from the anchor reached ollama: %v", up.requests())
}
if resp := roundTrip(t, addr, laneOllama, tAnchor, "POST", "/api/embed", `{"model":"anything"}`); resp.StatusCode != http.StatusOK {
t.Fatalf("the anchor still USES models: %d", resp.StatusCode)
}
}
func TestFilteredForwardFiltersTheModelLists(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
up := newFakeOllama(t)
addr := up.srv.Listener.Addr().String()
resp := roundTrip(t, addr, laneOllama, tMember, "GET", "/api/tags", "")
body := bodyOf(t, resp)
if strings.Contains(body, "llama3:70b") {
t.Fatalf("/api/tags offered a model this pairing cannot use: %s", body)
}
if !strings.Contains(body, "qwen4-embed") {
t.Fatalf("/api/tags dropped the shared model: %s", body)
}
// Fields the filter has never heard of must survive — it re-marshals the
// original entry rather than rebuilding it.
if !strings.Contains(body, `"size":42`) {
t.Fatalf("filtering must not strip fields off a kept entry: %s", body)
}
resp = roundTrip(t, addr, laneLLM, tMember, "GET", "/v1/models", "")
body = bodyOf(t, resp)
if strings.Contains(body, "llama3:70b") || !strings.Contains(body, "qwen4-embed") {
t.Fatalf("/v1/models was not filtered to the scope: %s", body)
}
if !strings.Contains(body, `"object":"list"`) {
t.Fatalf("the envelope around the list must survive: %s", body)
}
}
// filterModelListJSON on its own: the shapes both dialects use, and a body it
// cannot parse (which must be an error, never a pass-through).
// A list the filter has to REWRITE must arrive in a form it can read. Go's own
// transport asks for gzip unprompted, so without dropping Accept-Encoding on
// those paths the filter would meet compressed bytes and answer 502 for a
// perfectly good model list.
func TestModelListRequestsDoNotAskForCompression(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
seen := make(chan string, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Get("Accept-Encoding")
_, _ = w.Write([]byte(`{"models":[{"name":"qwen4-embed:latest"}]}`))
}))
defer srv.Close()
addr := srv.Listener.Addr().String()
client, server := net.Pipe()
go serveInferenceFiltered(server, addr, laneOllama, tMember)
req, _ := http.NewRequest("GET", "http://filter/api/tags", nil)
req.Header.Set("Accept-Encoding", "gzip")
_ = client.SetDeadline(time.Now().Add(10 * time.Second))
go func() { _ = req.Write(client) }()
resp, err := http.ReadResponse(bufio.NewReader(client), req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer client.Close()
if got := <-seen; got != "" {
t.Fatalf("the upstream was asked for %q on a path we rewrite", got)
}
if b := bodyOf(t, resp); !strings.Contains(b, "qwen4-embed") {
t.Fatalf("the filtered list did not come back: %s", b)
}
}
func TestFilterModelListJSON(t *testing.T) {
scope := scopeFromTerms([]string{"qwen4-embed"})
out, err := filterModelListJSON([]byte(`{"models":[{"model":"qwen4-embed:latest"},{"model":"x"}]}`),
"/api/tags", scope)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), `"x"`) {
t.Fatalf("the `model` key is ollama's other spelling and must be read too: %s", out)
}
if _, err := filterModelListJSON([]byte("not json"), "/api/tags", scope); err == nil {
t.Fatal("an unparsable list must be an error — passing it through unfiltered is the one thing this must not do")
}
}
// ── PII: refused unless named ──────────────────────────────────────────────
func TestPIILaneRefusedWhenScopedWithoutIt(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
refuse := piiScopeRefuser(tMember)
a, b := net.Pipe()
defer b.Close()
if !refuse(a) {
t.Fatal("a pairing scoped to models must be refused the sterilizer")
}
if _, err := a.Write([]byte("x")); err == nil {
t.Fatal("the refused connection must be closed")
}
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed", "pii"})
c, d := net.Pipe()
defer c.Close()
defer d.Close()
if piiScopeRefuser(tMember)(c) {
t.Fatal("naming 'pii' must open the sterilizer")
}
// Unscoped keeps what it had.
syncGrant(t, priv, tMember, []string{"inference"}, nil)
e, f := net.Pipe()
defer e.Close()
defer f.Close()
if piiScopeRefuser(tMember)(e) {
t.Fatal("an unscoped pairing must keep the PII lane")
}
}
// ── the heartbeat tells the same story ─────────────────────────────────────
func TestHeartbeatAdvertisesOnlyTheSharedModel(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
// One server stands in for ollama, the OpenAI-compatible server and the
// PII probe: each is asked a different path.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/api/tags"):
_, _ = w.Write([]byte(`{"models":[{"name":"qwen4-embed:latest"},{"name":"llama3:70b"}]}`))
case strings.HasPrefix(r.URL.Path, "/v1/models"):
_, _ = w.Write([]byte(`{"data":[{"id":"qwen4-embed"},{"id":"llama3:70b"}]}`))
default:
w.WriteHeader(http.StatusOK)
}
}))
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
t.Setenv("OLLAMA_ADDR", addr)
t.Setenv("PRIVATAE_LLM_ADDR", addr)
t.Setenv("PRIVATAE_PII_ADDR", addr)
var hb struct {
Caps []string `json:"capabilities"`
Models []string `json:"models"`
LLMModels []string `json:"llm_models"`
PiiPort *int `json:"pii_port"`
}
body := buildHeartbeatBody(tMember, "box", "linux", "test", "pub", "fp", addr, "", false, nil, nil, nil)
if err := json.Unmarshal([]byte(body), &hb); err != nil {
t.Fatal(err)
}
for _, m := range append(append([]string{}, hb.Models...), hb.LLMModels...) {
if strings.Contains(m, "llama3") {
t.Fatalf("the beat offered a model this pairing cannot use: %v / %v", hb.Models, hb.LLMModels)
}
}
if len(hb.Models) != 1 || !strings.HasPrefix(hb.Models[0], "qwen4-embed") {
t.Fatalf("models = %v, want just the shared one", hb.Models)
}
if hb.PiiPort != nil {
t.Fatalf("pii_port travelled for a scoped pairing that did not name 'pii': %v", *hb.PiiPort)
}
for _, c := range hb.Caps {
if c == "pii" {
t.Fatal("the beat advertised the sterilizer to a pairing scoped to models")
}
}
// Nothing shared that this box actually serves: the lane is withheld
// entirely rather than advertised and then refused at every call.
syncGrant(t, priv, tMember, []string{"inference"}, []string{"not-on-this-box"})
body = buildHeartbeatBody(tMember, "box", "linux", "test", "pub", "fp", addr, "", false, nil, nil, nil)
hb.Caps, hb.Models = nil, nil
if err := json.Unmarshal([]byte(body), &hb); err != nil {
t.Fatal(err)
}
for _, c := range hb.Caps {
if c == "embed" || c == "llm-openai" {
t.Fatalf("a scope with no model on this box must withhold the lane, got caps %v", hb.Caps)
}
}
}
// The unscoped beat is unchanged — the regression guard for every pairing that
// predates this field.
func TestHeartbeatUnchangedForAnUnscopedPairing(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, nil)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/tags") {
_, _ = w.Write([]byte(`{"models":[{"name":"a:latest"},{"name":"b:latest"}]}`))
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
t.Setenv("OLLAMA_ADDR", addr)
var hb struct {
Caps []string `json:"capabilities"`
Models []string `json:"models"`
}
if err := json.Unmarshal([]byte(buildHeartbeatBody(tMember, "box", "linux", "test", "pub", "fp", addr, "", false, nil, nil, nil)), &hb); err != nil {
t.Fatal(err)
}
if len(hb.Models) != 2 {
t.Fatalf("an unscoped pairing must see every model: %v", hb.Models)
}
}
// ── belt: the request body is read, not streamed past the check ────────────
func TestOversizedBodyIsRefusedNotForwarded(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
up := newFakeOllama(t)
big := bytes.Repeat([]byte("a"), 16)
// A body whose declared length exceeds the cap would be refused; here the
// point is only that the request is BUFFERED before the verdict, so the
// model check reads the same bytes the upstream would have.
resp := roundTrip(t, up.srv.Listener.Addr().String(), laneOllama, tMember, "POST", "/api/embed",
`{"input":"`+string(big)+`"}`)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("a request naming no model must be refused: %d", resp.StatusCode)
}
if len(up.requests()) != 0 {
t.Fatalf("it reached the upstream anyway: %v", up.requests())
}
}
// ── what review turned up ──────────────────────────────────────────────────
// A pairing holding BOTH `models` and a model list could rename any model on
// the box INTO its scope: /api/copy {"source":"llama3:70b","destination":
// "qwen4-embed"}, then use it. A ceiling you can walk around by renaming is not
// a ceiling, so a scoped pairing does not manage models whatever else it holds.
func TestAScopedPairingCannotRenameAModelIntoItsScope(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference", "models"}, []string{"qwen4-embed"})
up := newFakeOllama(t)
addr := up.srv.Listener.Addr().String()
if !inferenceFilterNeeded(tMember, laneOllama, pairingModelScope(tMember)) {
t.Fatal("a scoped pairing must be filtered even when it also holds `models`")
}
for _, p := range []string{"/api/copy", "/api/create", "/api/pull", "/api/delete"} {
resp := roundTrip(t, addr, laneOllama, tMember, "POST", p,
`{"source":"llama3:70b","destination":"qwen4-embed"}`)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s: status %d, want 403 — the scope must outrank the cap", p, resp.StatusCode)
}
if b := bodyOf(t, resp); !strings.Contains(b, "scoped") {
t.Fatalf("%s: the refusal must say why: %s", p, b)
}
}
if len(up.requests()) != 0 {
t.Fatalf("a rename reached ollama: %v", up.requests())
}
}
// The deny list is matched on a CLEANED path. `/api/pull/` and `//api/pull` are
// the same endpoint to the upstream's router but three different map keys, and
// an exact-match deny list that misses two of them has two holes.
func TestStorePathVariantsAreStillRefused(t *testing.T) {
scope := unscopedModels()
for _, p := range []string{"/api/pull/", "//api/pull", "/API/Pull", "/api/./pull", "/api/foo/../pull"} {
req, err := http.NewRequest("POST", "http://x"+p, nil)
if err != nil {
t.Fatalf("%s: %v", p, err)
}
got := requestPath(req)
if why, _ := filterVerdict(got, nil, laneOllama, scope, false); why == "" {
t.Fatalf("%q normalised to %q and was ALLOWED — the store deny list has a hole", p, got)
}
}
}
// A PRESENT list is a scope even when nothing in it survives trimming. The
// first cut set `scoped` only when it kept an entry, so a malformed ceiling
// failed open into no ceiling at all.
func TestAnAllBlankModelListIsStillAScope(t *testing.T) {
s := scopeFromTerms([]string{"", " "})
if !s.scoped || s.allowsModel("anything") || s.allowsPII() {
t.Fatalf("a present-but-blank list must be the narrowest scope, got %+v", s)
}
if unscopedModels().scoped {
t.Fatal("an ABSENT list is still unscoped")
}
}
// Two requests over ONE connection: keep-alive has to survive a refusal, and
// the scope is re-read each time, so a grant narrowed mid-connection takes
// effect on the very next request rather than when the peer happens to hang up.
func TestOneConnectionCarriesSeveralRequestsAndRereadsTheGrant(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed", "llama3:70b"})
up := newFakeOllama(t)
client, server := net.Pipe()
defer client.Close()
go serveInferenceFiltered(server, up.srv.Listener.Addr().String(), laneOllama, tMember)
_ = client.SetDeadline(time.Now().Add(20 * time.Second))
br := bufio.NewReader(client)
send := func(model string) int {
req, _ := http.NewRequest("POST", "http://filter/api/embed",
strings.NewReader(`{"model":"`+model+`","input":"x"}`))
req.Header.Set("Content-Type", "application/json")
errc := make(chan error, 1)
go func() { errc <- req.Write(client) }()
resp, err := http.ReadResponse(br, req)
if err != nil {
t.Fatalf("read: %v", err)
}
if err := <-errc; err != nil {
t.Fatalf("write: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp.StatusCode
}
if code := send("qwen4-embed"); code != http.StatusOK {
t.Fatalf("first request: %d", code)
}
if code := send("llama3:70b"); code != http.StatusOK {
t.Fatalf("second request on the same connection: %d", code)
}
// The anchor narrows the grant while the connection is open and idle.
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
if code := send("llama3:70b"); code != http.StatusForbidden {
t.Fatalf("a narrowed grant must bite on the NEXT request, got %d", code)
}
if code := send("qwen4-embed"); code != http.StatusOK {
t.Fatalf("...and the model still shared must keep working: %d", code)
}
}
// A streaming answer must stream, and a HEAD must come back without a body.
func TestStreamingAndHeadSurviveTheFilter(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
fl, _ := w.(http.Flusher)
for i := 0; i < 3; i++ { // chunked, no Content-Length
_, _ = w.Write([]byte(`{"response":"tok"}` + "\n"))
if fl != nil {
fl.Flush()
}
}
}))
defer srv.Close()
addr := srv.Listener.Addr().String()
resp := roundTrip(t, addr, laneOllama, tMember, "POST", "/api/generate",
`{"model":"qwen4-embed","prompt":"x"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d", resp.StatusCode)
}
if b := bodyOf(t, resp); strings.Count(b, "tok") != 3 {
t.Fatalf("the streamed chunks did not all arrive: %q", b)
}
if resp.ContentLength != -1 {
t.Fatalf("a streamed answer must stay unlengthed, got %d", resp.ContentLength)
}
head := roundTrip(t, addr, laneOllama, tMember, "HEAD", "/api/version", "")
if head.StatusCode != http.StatusOK {
t.Fatalf("HEAD: %d", head.StatusCode)
}
if b := bodyOf(t, head); b != "" {
t.Fatalf("a HEAD answer must carry no body, got %q", b)
}
}
// An Expect: 100-continue would make ReadResponse hand back the interim 1xx as
// though it were the answer, leaving the real one unread and desyncing the
// connection. The body is already buffered here, so the header is dropped.
func TestExpectContinueDoesNotDesyncTheConnection(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
gotExpect := make(chan string, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotExpect <- r.Header.Get("Expect")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
client, server := net.Pipe()
defer client.Close()
go serveInferenceFiltered(server, srv.Listener.Addr().String(), laneOllama, tMember)
req, _ := http.NewRequest("POST", "http://filter/api/embed",
strings.NewReader(`{"model":"qwen4-embed","input":"x"}`))
req.Header.Set("Expect", "100-continue")
_ = client.SetDeadline(time.Now().Add(15 * time.Second))
errc := make(chan error, 1)
go func() { errc <- req.Write(client) }()
resp, err := http.ReadResponse(bufio.NewReader(client), req)
if err != nil {
t.Fatalf("read: %v", err)
}
defer resp.Body.Close()
if err := <-errc; err != nil {
t.Fatalf("write: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("got %d — an interim 1xx was forwarded as the answer", resp.StatusCode)
}
if e := <-gotExpect; e != "" {
t.Fatalf("the upstream was asked for %q; the body was already in hand", e)
}
}
// One approved peer must not be able to push unbounded bytes into this process
// by never terminating a header. http.ReadRequest has no cap of its own.
func TestAnEndlessHeaderIsCutOff(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
up := newFakeOllama(t)
client, server := net.Pipe()
done := make(chan struct{})
go func() {
serveInferenceFiltered(server, up.srv.Listener.Addr().String(), laneOllama, tMember)
close(done)
}()
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
go func() {
_, _ = client.Write([]byte("POST /api/embed HTTP/1.1\r\nHost: x\r\nX-Pad: "))
junk := bytes.Repeat([]byte("a"), 64<<10)
for i := 0; i < (maxFilteredHeaderBytes/len(junk))+8; i++ {
if _, err := client.Write(junk); err != nil {
return
}
}
}()
select {
case <-done: // the filter gave up, which is the whole point
case <-time.After(25 * time.Second):
t.Fatal("an endless header was read without limit")
}
client.Close()
if len(up.requests()) != 0 {
t.Fatalf("it reached the upstream: %v", up.requests())
}
}
// WHAT IS RUNNING ON SOMEONE'S COMPUTER IS NOT A SYSTEM RESOURCE.
//
// `agents` and `schedulers` rode every beat to every paired host regardless of
// its ceiling — scanAgents() takes no pairing identity — so a host approved for
// one model still received the machine's process list and its scheduled jobs.
// That made the sentence this whole feature exists for false.
func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
t.Setenv("OLLAMA_ADDR", "127.0.0.1:1")
// The tool-server reader looks in an empty home, not this machine's.
oldHomes, oldSys := mcpHomesFn, mcpSystemFn
t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldHomes, oldSys })
empty := t.TempDir()
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: empty, ConfigDir: empty}}, true }
mcpSystemFn = func() []mcpSource { return nil }
beat := func(self string) map[string]any {
t.Helper()
var m map[string]any
body := buildHeartbeatBody(self, "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatal(err)
}
return m
}
hasCap := func(m map[string]any, want string) bool {
list, _ := m["capabilities"].([]any)
for _, c := range list {
if s, _ := c.(string); s == want {
return true
}
}
return false
}
// A pairing scoped to one model holds no `observe`: the payload is ABSENT,
// not empty — an empty list would read as "nothing is running here".
syncGrant(t, priv, tMember, []string{"inference"}, []string{"qwen4-embed"})
m := beat(tMember)
for _, k := range []string{"agents", "agent_scan", "agent_scan_ok", "schedulers", "scheduler_scan",
// The store root names the operator's OS account; it is path material
// like the rest and travels only with `observe`. The holes map travels
// with it: absent must mean "not entitled", and only that.
"machine_record_root", "machine_record_dropped",
// The tool servers agent clients declare name files, servers and
// command lines: observe material like the agent list.
"tool_servers", "tool_server_scan"} {
if _, present := m[k]; present {
t.Fatalf("%q travelled to a pairing that cannot observe — that is the machine's "+
"process list going to a host approved for one model", k)
}
}
if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") || hasCap(m, "tool-server-observe") {
t.Fatalf("the observe capabilities were advertised to a pairing outside the family: %v",
m["capabilities"])
}
// Granted `observe`, the same beat carries them again.
syncGrant(t, priv, tMember, []string{"inference", "observe"}, []string{"qwen4-embed"})
m = beat(tMember)
if r, _ := m["machine_record_root"].(string); r == "" {
t.Fatal("granted observe, the beat must say where the machine's record is")
}
if _, present := m["agents"]; !present {
t.Fatal("a pairing granted observe must still get the agent list")
}
if _, present := m["schedulers"]; !present {
t.Fatal("a pairing granted observe must still get the scheduler inventory")
}
if _, present := m["tool_servers"]; !present || !hasCap(m, "tool-server-observe") {
t.Fatal("a pairing granted observe must get the declared tool servers and their capability")
}
// The anchor holds every family, and an unanchored box gates nothing — the
// two cases that must not change, which between them cover nearly every install.
if _, present := beat(tAnchor)["agents"]; !present {
t.Fatal("the anchor pairing must keep the observe payload")
}
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
p, _ := modePath()
if err := os.WriteFile(p, []byte(`{"mode":"local"}`), 0o600); err != nil {
t.Fatal(err)
}
writePairing(t, tAnchor)
if _, present := beat(tAnchor)["agents"]; !present {
t.Fatal("a local bastion gates nothing and must be unchanged")
}
}