diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 8f38f25..8de46ac 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -417,6 +417,28 @@ func TestRequirement_SwapModel(t *testing.T) { } } +// TestRequirement_ListMutation — the three methods that change what is on his +// lists sit on one rung. IngestMail joined them on 2026-08-01; it used to be +// AuthRead, which made it disagree with SetTaskStatus about the same question. +// CaptureTask stays a read: it puts one line on a list he asked for. +func TestRequirement_ListMutation(t *testing.T) { + for _, m := range []ipc.Method{ + ipc.MethodIngestMail, ipc.MethodSetTaskStatus, + } { + if got := Requirement(m); got != AuthWrite { + t.Errorf("%s authority = %v; want AuthWrite", m, got) + } + } + if got := Requirement(ipc.MethodCaptureTask); got != AuthRead { + t.Errorf("CaptureTask authority = %v; want AuthRead", got) + } + // mavmaild keeps working: AuthWrite outside WriteFact only needs enrollment. + maild := Scope{Surface: SurfaceCoreProcess, Module: "mavmaild", SourceScope: []string{"mail:*"}} + if err := Can(ipc.MethodIngestMail, maild, nil); err != nil { + t.Errorf("mavmaild ingesting mail = %v; want allowed", err) + } +} + // TestRequirement_Capture — recording other people is a write, not a read: it // puts audio of them on disk. The read side, "что ты записываешь?", is not. // diff --git a/internal/auth/policy.go b/internal/auth/policy.go index 7936a52..ede1e77 100644 --- a/internal/auth/policy.go +++ b/internal/auth/policy.go @@ -93,6 +93,20 @@ func Requirement(m ipc.Method) Authority { return AuthWrite case ipc.MethodWriteFact: return AuthWrite + case ipc.MethodIngestMail: + // Mail ingestion (Vikunja #246). Moved up from AuthRead on 2026-08-01, + // for consistency with SetTaskStatus rather than for a new threat: both + // answer the same question — may this module change what is on his + // lists? — and they were answering it differently. The old argument was + // that ingestion is additive and can only produce candidate tasks, which + // is still true; it is the weaker half of the argument, because a + // compromised mail reader that can fill the review page indefinitely is + // not a read. + // + // No caller loses anything: AuthWrite outside WriteFact only requires + // enrollment, which mavmaild already has, and the method does not exist + // unless the operator wired a mail block. + return AuthWrite case ipc.MethodSetTaskStatus: // Resolving a task is NOT additive, which is what separates it from // capture. Capture at AuthRead can only put a line on a list he reads @@ -122,11 +136,6 @@ func Requirement(m ipc.Method) Authority { // something. ipc.MethodCaptureTask, ipc.MethodListTasks, - // 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