aa1a26532c
Maven can record a meeting when she is told to, transcribe it through the STT she already has, and write a summary note. The audio lives in the blob store #252 introduced, under the same retention loop. Nothing here listens. Recorder.Append is the only way audio enters and it refuses every frame unless someone explicitly started a session, so audio arriving at an idle core is dropped rather than buffered. The plan document asked for a keyword trigger ("maven record" heard in the room) and that is refused: noticing a keyword means listening to the room, which is the one behaviour this capability must not have. Off unless configured twice over. No media block means nowhere to keep audio, no capture block means no recorder, and in either case the four IPC methods answer ErrUnknownMethod. On an unconfigured box there is no wire path that begins a recording at all. A forgotten session ends itself at max_minutes, checked on every append, and the audio collected before the cap is kept. Stop with discard set is what "забудь, не записывай" maps to and it leaves nothing behind. The verbatim transcript is not saved unless save_transcript says so; the summary is. Long audio against n_ctx 4096 is handled by map-reduce over 3000-rune windows rather than by truncation, because a truncated meeting summary reads as complete and is not. Transcription is windowed at five minutes so the whisper worker stays responsive to the voice path. No second STT: internal/capture takes the stt.Transcriber the voice path already holds. Capture with voice off is refused rather than degraded, since hours of unreadable audio of other people is worse than no recording. The three write methods are AuthWrite, not AuthStepUp: step-up needs a passkey gesture the voice path cannot make, which would leave "запиши встречу" impossible by voice. capture_status is AuthRead. make build and make test both pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
224 lines
9.7 KiB
Go
224 lines
9.7 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.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop:
|
|
// Recording a meeting (Vikunja #253). AuthWrite, not AuthRead: it puts
|
|
// audio of other people on disk, which is a heavier thing than reading a
|
|
// fact, and it is not something a read-only surface should be able to
|
|
// begin. Append and Stop sit on the same rung as Start deliberately —
|
|
// a surface that may not start a recording has no business feeding or
|
|
// harvesting one either.
|
|
//
|
|
// Not AuthStepUp, and this is the interesting line: step-up needs a
|
|
// passkey gesture, which the voice path cannot make. Putting it here
|
|
// would mean "запиши встречу" could never work by voice, and the real
|
|
// gate on this capability is elsewhere and stronger — the methods do not
|
|
// exist at all unless the operator enabled a capture block, and no
|
|
// recording can begin without someone saying so.
|
|
return AuthWrite
|
|
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,
|
|
// Looking at one image (Vikunja #252). AuthRead because of what it can
|
|
// produce: words about a picture, and optionally a note. It cannot write
|
|
// a fact, set a reminder, or touch the tool allowlist. The invasive part
|
|
// of this capability is not the authority rung — it is that the bytes are
|
|
// kept on disk, which media.retention bounds, and that they never leave
|
|
// the box, which internal/vision enforces by refusing a non-private
|
|
// endpoint.
|
|
ipc.MethodDescribeImage,
|
|
// "что ты записываешь?" — the read side of the recorder. It reports a
|
|
// label, a start time and a byte count, begins nothing and keeps nothing.
|
|
ipc.MethodCaptureStatus,
|
|
// 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
|
|
}
|