diff --git a/internal/config/uploads.go b/internal/config/uploads.go index f428eba..442ba26 100644 --- a/internal/config/uploads.go +++ b/internal/config/uploads.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "regexp" "strconv" "strings" "time" @@ -116,6 +117,9 @@ func loadUploadConfig() (UploadConfig, error) { if err := u.validateReconcile(); err != nil { return UploadConfig{}, err } + if !IsLocalModel(u.UploadChatModel) { + return UploadConfig{}, fmt.Errorf("config: TAPIR_UPLOAD_CHAT_MODEL=%q is not a local model (koala/ or iguana/): the whole private transcript is sent to it", u.UploadChatModel) + } chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars) if err != nil { @@ -141,18 +145,18 @@ func positiveInt(key string, fallback int) (int, error) { return n, nil } -// localPrefixes are the host prefixes of the models that run on the homelab. Private -// meeting content may be sent to these and to nothing else for reconciliation -// (ADR-034): a cloud alias is refused at start-up rather than discovered later. -var localPrefixes = []string{"koala/", "iguana/"} +// localAlias is the shape of a model alias that runs on the homelab: a local host +// prefix, then one plain name. A bare prefix check let through aliases that are shaped +// like an escape (koala/../berget/x, koala//x, koala/x?host=berget): whether the +// gateway would route them elsewhere is unknown, so they are refused (ADR-035 review). +var localAlias = regexp.MustCompile(`^(koala|iguana)/[A-Za-z0-9][A-Za-z0-9._:-]*$`) -func isLocalModel(alias string) bool { - for _, p := range localPrefixes { - if strings.HasPrefix(alias, p) { - return true - } - } - return false +// IsLocalModel reports whether alias is a local model. Private meeting content may be +// sent to these and to nothing else: reconciliation (ADR-034) and chat over an uploaded +// recording (ADR-035) are both held to it, and a cloud alias is refused at start-up +// rather than discovered later. +func IsLocalModel(alias string) bool { + return localAlias.MatchString(alias) && !strings.Contains(alias, "..") } // validateReconcile checks the two-engine settings. They are inert while no base @@ -168,7 +172,7 @@ func (u UploadConfig) validateReconcile() error { {"TAPIR_RECONCILE_BASE_MODEL", u.ReconcileBaseModel}, {"TAPIR_RECONCILE_MODEL", u.ReconcileModel}, } { - if !isLocalModel(m.alias) { + if !IsLocalModel(m.alias) { return fmt.Errorf("config: %s=%q is not a local model (koala/ or iguana/): private meeting content must not leave the homelab for reconciliation", m.name, m.alias) } }