vision: scope the note, settle the contract, wait for the prune
Saving a description writes recall corpus. writeNote embeds it under media:image:<id>, a source no enrollment owns, and the method sits at AuthRead, so any enrolled module could put a small VLM's guess into what Maven knows and have it come back in a later turn as something she believes. The describing half stays a read; save_note is now held to the same source-scope rule WriteFact is, and the stored text carries a marker saying it came off a picture. Three doc comments said the method exists only when vision is enabled and the code says otherwise. The code is right, and storing without describing is the state this box is in, so the comments were corrected rather than the behaviour. A request carrying both data and id used to take the id branch and drop the bytes without a word; it is refused. A media dir that cannot be created and a vision endpoint that is a typo were logged at wiring time and the capability just stayed off, which is the hardest kind of misconfiguration to notice. Both fail at startup. runPrune was the one loop started with a bare go and not in the daemon's WaitGroup, so shutdown did not wait for a prune that was deleting files. Found in review of #72.
This commit is contained in:
@@ -472,3 +472,30 @@ func TestRequirement_Speaker(t *testing.T) {
|
||||
t.Errorf("voice listing speakers = %v; want allowed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Describing an image is a read. Saving the description is a write of recall
|
||||
// corpus under a source no enrollment owns, so it is held to the same
|
||||
// source-scope rule WriteFact is. Before this, any AuthRead caller could put a
|
||||
// small VLM's guess into what Maven knows.
|
||||
func TestCan_DescribeImage_SaveNoteNeedsScope(t *testing.T) {
|
||||
poller := Scope{Surface: SurfaceTelegram, Module: "poll", SourceScope: []string{"poll:healthcheck"}}
|
||||
web := Scope{Surface: SurfaceAuthedPage, Module: "web", SourceScope: []string{"*"}}
|
||||
|
||||
plain, err := json.Marshal(ipc.DescribeImageReq{Data: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noting, err := json.Marshal(ipc.DescribeImageReq{Data: []byte("x"), SaveNote: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Can(ipc.MethodDescribeImage, poller, plain); err != nil {
|
||||
t.Errorf("describing without saving must stay a read: %v", err)
|
||||
}
|
||||
if err := Can(ipc.MethodDescribeImage, poller, noting); !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("save_note out of scope = %v, want ErrForbidden", err)
|
||||
}
|
||||
if err := Can(ipc.MethodDescribeImage, web, noting); err != nil {
|
||||
t.Errorf("a module scoped to everything must still be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,18 @@ func Can(m ipc.Method, scope Scope, params json.RawMessage) error {
|
||||
|
||||
switch Requirement(m) {
|
||||
case AuthRead:
|
||||
// Describing an image is a read. Saving the description as a note is
|
||||
// not: writeNote embeds it, so it comes back in a later turn as
|
||||
// something Maven knows, under the source media:image:<id>, which no
|
||||
// enrollment owns. The rung's own argument was that the method "cannot
|
||||
// write a fact, set a reminder, or touch the tool allowlist" — it can
|
||||
// write recall corpus, and that is what AuthWrite exists to scope. So
|
||||
// the note half is held to the same source-scope rule WriteFact is.
|
||||
if m == ipc.MethodDescribeImage && wantsNote(params) {
|
||||
if !SourceAllowed(scope.SourceScope, ImageNoteSource) {
|
||||
return fmt.Errorf("%w: source %q out of scope", ErrForbidden, ImageNoteSource)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -214,6 +226,26 @@ func Can(m ipc.Method, scope Scope, params json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImageNoteSource is the source scope a caller needs to turn a described image
|
||||
// into a note. The note itself is stored under "media:image:<id-prefix>"; the
|
||||
// scope is checked against this stem, because the id is not known until the
|
||||
// bytes arrive and no enrollment could name it in advance.
|
||||
const ImageNoteSource = "media:image"
|
||||
|
||||
// wantsNote reports whether a DescribeImage call asked for the description to
|
||||
// be remembered. Malformed params read as no: dispatch rejects them a moment
|
||||
// later with a better error.
|
||||
func wantsNote(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var p ipc.DescribeImageReq
|
||||
if json.Unmarshal(raw, &p) != nil {
|
||||
return false
|
||||
}
|
||||
return p.SaveNote
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user