Files
Maven/internal/auth/policy.go
T
kami ad074cea31 Swap the resident model without restarting mavend (#250)
Loading a different gguf was a one-line edit to phraser.model_path plus a
restart. It is now an owner-triggered IPC call, off unless configured.

internal/phraser/swap.go holds the safety properties as code:

  - Never two models resident. The old llama-server is killed and reaped
    before the new one is launched. One 1.7B fits the Vega iGPU; a
    blue/green overlap would OOM the box, so it is not offered.
  - Atomic from a turn's point of view. Swap drains the in-flight turns
    (they finish on the old model), then refuses arrivals with ErrSwapping
    until the new server has answered /v1/models. No turn ever sees half a
    swap; refused turns fall back to the classifier cascade.
  - A failed load rolls back. If the new model does not start or does not
    probe, the previous one is reloaded and the call returns RolledBack
    with the error. If the rollback also fails the daemon says so and
    degrades to the classifier rather than pretending to serve.

Holders of the completion client are re-pointed, not rebuilt: llm.Client
guards its base URL and LLMPhraser.OnSwap re-points it, so the router, the
replier, the mail extractor and the memory evaluator follow the new port
without knowing a swap happened.

Reach is deliberately narrow. phraser.swap_models is an exact-match
allowlist of absolute paths a human wrote, rejected at startup otherwise,
so "swap the model" can never mean "load any file on my disk"; the running
model is always swappable back to. MethodSwapModel is AuthStepUp, the same
rung as mutating the tool allowlist, and /models gates POST through the
same stepUpOK the tools page uses. Nothing calls Swap on a timer and no
act, intent or utterance reaches it.

Vikunja #250
2026-08-01 03:59:08 +04:00

198 lines
8.1 KiB
Go

package auth
import (
"encoding/json"
"errors"
"fmt"
"github.com/kami/maven/internal/ipc"
)
// Authority — a discrete authority requirement per ipc.Method. Higher
// numbers are STRICTER (need a higher layer to be granted). Today's CoreAPI
// methods are all read or single-module-write; the deferred L3 acts
// (EnableTool / destructive Ops) are reserved at the top rung — they're
// not on the CoreAPI yet (the tool-executor module is unbuilt), but the
// authority table holds the rung so adding them is a policy entry, not a
// new mechanism.
type Authority int8
const (
// AuthRead — read methods (LatestFact, LatestFactBySource, Since, Presence,
// RecentOutcomes) and state mutations a module legitimately makes
// (CreateReminder, MarkReminder, RecordNudge, ResolveNudge). The Enrollment
// already gated caller identity; any enrolled module may use these.
AuthRead Authority = 0
// AuthWrite — WriteFact. Need enrollment + source-scope match. The
// "compromised poller can't forge a trigger" property: a module only writes
// sources it owns. Floor gets "*"; tight enrollments scope per source.
AuthWrite Authority = 1
// AuthStepUp — a per-assertion user-verification gesture is required for
// this call. Reserved for EnableTool (registration-enable) and destructive
// acts when they land on the CoreAPI. NOT a Layer itself — Authority is
// the call-side requirement; Layer is the surface-side capability. The
// pure check is just: does the surface cap (MaxLayer) carry L3, AND was
// step-up asserted this session? Both settled by Can below.
AuthStepUp Authority = 2
)
// Requirement — the PURE authority table: per-method required Authority. This
// is the one place in the codebase a method's required authority is declared;
// every other reference to "registration needs step-up" points back here.
// Adding a new ipc.Method = a row here (or it inherits AuthRead by default,
// which the vet check in dispatch catches via Method existence, not auth).
func Requirement(m ipc.Method) Authority {
switch m {
case ipc.MethodEnableTool, ipc.MethodDisableTool:
// Both mutate the tool allowlist — the boundary. Enable adds a runnable
// capability (privilege escalation); disable removes one (fail-safe
// direction, but still an allowlist mutation and a lever an attacker
// could pull to silence a security-relevant tool). Human-only, step-up
// asserted — never a module or the voice/chat path. maven can propose
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
return AuthStepUp
case ipc.MethodSwapModel:
// Swapping the resident model changes what routes every utterance and
// what words every reply. It is the owner's call, from a surface that can
// carry a passkey gesture — the same rung as mutating the tool allowlist,
// and for the same reason: nothing Maven says or does may reach it.
// MethodModelStatus is only the read side, so it stays at AuthRead.
return AuthStepUp
case ipc.MethodWriteFact:
return AuthWrite
case ipc.MethodAssertStepUp:
return AuthRead
case ipc.MethodLatestFact,
ipc.MethodLatestFactBySource,
ipc.MethodSince,
ipc.MethodPresence,
ipc.MethodRecentOutcomes,
ipc.MethodCreateReminder,
ipc.MethodMarkReminder,
ipc.MethodRecordNudge,
ipc.MethodResolveNudge,
// Task capture (Vikunja #130). Listed explicitly rather than left to
// the default so the intent is on the record: capturing a task is a
// module write, not an allowlist mutation and not a new standing reason
// for Maven to speak — nothing in the tick loop reads tasks. It stays
// at AuthRead, the same rung as CreateReminder, which is the closest
// existing analogue.
ipc.MethodCaptureTask,
ipc.MethodListTasks,
ipc.MethodSetTaskStatus,
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
// produce: candidate tasks and nothing else. It cannot write a fact, set a
// reminder, or touch the tool allowlist, so a compromised mail reader can
// at worst put junk on a review page he clears in one click.
ipc.MethodIngestMail,
// The read side of the model swap: which model is resident, which ones are
// allowlisted. It loads nothing and changes nothing.
ipc.MethodModelStatus:
return AuthRead
}
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
// regardless of the auth verdict (we run before dispatch; we don't gate on
// Method existence — Check is method-agnostic policy, not routing).
return AuthRead
}
// Can — the PURE authority decision for one call. Returns nil if the scope
// is authorized to invoke m with the supplied params; an error otherwise:
//
// - ErrUnenrolled — scope has no Module (caller wasn't in the enrollment
// table). Fail closed.
// - ErrForbidden — surface caps the layer below what m requires, or
// WriteFact's source is out of scope.
//
// The adapter wrapping this for the ipc gate (Gate.Check) maps the error to
// codeForbidden at the wire; we keep the distinction here so daemon logs
// can show why a call was denied.
//
// params is the raw json.RawMessage the ipc Server received; for WriteFact we
// re-parse Source out of it. Other methods don't need params — their verdict
// depends only on the scope.
func Can(m ipc.Method, scope Scope, params json.RawMessage) error {
// Floor closed: any caller not in the enrollment table is refused outright.
// Not "0-level unauthed" — refused. This is the surface-caps property
// applied before the layer caps: there is no L0 surface if SurfaceUnknown.
if scope.Module == "" {
return ErrUnenrolled
}
if scope.Surface == SurfaceUnknown {
return ErrUnenrolled
}
switch Requirement(m) {
case AuthRead:
// Any enrolled module may read. Reads through the surface level the
// Enrollment set (voice-L0 wouldn't be enrolled to write at all).
return nil
case AuthWrite:
if m == ipc.MethodWriteFact {
src, err := extractSource(params)
if err != nil {
// Malformed params is a bad-params error already produced by
// ipc.dispatch; but Can runs first. Treat as forbidden — a
// caller doesn't get to probe scopes with garbage params.
return fmt.Errorf("%w: malformed source", ErrForbidden)
}
if !SourceAllowed(scope.SourceScope, src) {
// The spec's compromised-poller case in one line: a poller
// enrolled to write poll:healthcheck asking to write
// poll:uptime is denied — but the same poller writing
// poll:healthcheck is fine. Polls can't forge triggers.
return fmt.Errorf("%w: source %q out of scope", ErrForbidden, src)
}
}
return nil
case AuthStepUp:
// Surface-caps-authority enforced here. The surface can't carry L3 ⇒
// forbidden. The session step-up itself is checked by the Gate (it
// owns Session state and surfaces a Check function); we cap surface
// here so the gate fails closed on shape alone.
if MaxLayer(scope.Surface) < Layer3 {
return fmt.Errorf("%w: surface %s can't carry step-up", ErrForbidden, scope.Surface)
}
return nil
}
return nil
}
// SourceAllowed — true iff src is in scope (the wildcard "*" matches all).
// Empty scope ⇒ fail closed. The function is pure; we keep it exported so a
// future enrollment table can call into the same matching logic.
func SourceAllowed(scope []string, src string) bool {
if len(scope) == 0 {
return false
}
for _, s := range scope {
if s == "*" || s == src {
return true
}
}
return false
}
// extractSource reads WriteFactReq.Source out of the raw params WITHOUT a full
// unmarshal — Source is the only field Can needs, and re-parsing it once per
// write is cheap (and only happens on MethodWriteFact). Stay independent of
// any future WriteFactReq shape changes by using the struct directly.
func extractSource(raw json.RawMessage) (string, error) {
var p ipc.WriteFactReq
if err := json.Unmarshal(raw, &p); err != nil {
return "", err
}
if p.Source == "" {
// An empty source is rejected by store.WriteFact anyway; surface it
// as forbidden to avoid giving a caller an ipc sentinel that names the
// store's internal invariant. (store rejects this before feature flag
// for "missing source"; today this is best-effort.)
return "", errors.New("empty source")
}
return p.Source, nil
}