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:
kami
2026-08-01 14:21:46 +04:00
parent e926e4e6df
commit 543aefde4b
9 changed files with 266 additions and 24 deletions
+27
View File
@@ -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)
}
}