Skip to main content

sioul/mcp/
access.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Consent per project, as every tool keeps to it (docs/mcp.md, "Per-project
5//! access"; the rule itself is the core's: `sioul_core::consent`).
6//!
7//! Every project is closed to agents until the person opens it (`ai = true`
8//! in `sioul-projects.toml`); things in no project follow `[mcp]
9//! outside_projects`. A tool that lists leaves out what is closed and says
10//! how many things it left out, never which: the agent knows something is
11//! there and does not invent it. A tool that reads one thing, or writes into
12//! one, refuses a closed one, saying so plainly.
13//!
14//! The mail of work and school Microsoft accounts is kept whatever its
15//! projects, unless the person opens it (`[mcp] work_accounts`): Microsoft's
16//! terms count the organisation among whom must allow it (docs/microsoft.md).
17
18use crate::Session;
19use sioul_core::consent::Consent;
20use sioul_core::links::{self, Kind, LocalLinks, Loaded, World};
21use sioul_core::mailindex::MailIndex;
22use sioul_core::tasks::Task;
23
24/// What a thing kept from agents is called where its words would be.
25pub const KEPT: &str = "(kept from agents)";
26
27/// What the person opened to agents, and what each thing belongs to, from
28/// everything read; mail as the Porch's checks take it (`porch::Gate`), so
29/// that forged mail claiming a client never reaches an agent through the
30/// client's project. The gate remembers its verdicts for the server's life.
31pub fn of(s: &Session, loaded: &Loaded) -> Consent {
32    let gate = sioul_core::porch::Gate::load(&s.config, jiff::Timestamp::now().as_second());
33    let mut consent = Consent::of(loaded, s.config.mcp.outside_projects, Some(std::sync::Arc::new(gate)));
34    consent.keep_accounts(kept_accounts(&s.config), &loaded.mail);
35    consent
36}
37
38/// The accounts whose mail is kept from agents whatever its projects, with
39/// their mail folders: work and school Microsoft accounts (switched off ones
40/// too: their mail stays on disk), unless `[mcp] work_accounts` opens them.
41pub fn kept_accounts(config: &sioul_core::config::Config) -> Vec<(String, std::path::PathBuf)> {
42    if config.mcp.work_accounts {
43        return Vec::new();
44    }
45    config.every_account().filter(|a| a.is_microsoft_work()).map(|a| (a.id.clone(), a.maildir_path())).collect()
46}
47
48/// The same for mail alone, lighter: the projects, Sioul's own ties, and
49/// `index` (the mail's headers), which comes back with it.
50pub fn for_mail(s: &Session, index: MailIndex) -> (Consent, MailIndex) {
51    let projects = s.config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|store| store.projects).unwrap_or_default();
52    let loaded = Loaded { projects, mail: index, local: LocalLinks::load(&LocalLinks::default_path()), ..Loaded::default() };
53    let consent = of(s, &loaded);
54    (consent, loaded.mail)
55}
56
57/// What the agent is told when things were left out: how many, never which.
58pub fn left_out(count: usize) -> Option<String> {
59    (count > 0).then(|| {
60        let things = if count == 1 { "1 more thing belongs".to_string() } else { format!("{count} more things belong") };
61        format!("({things} to projects the person keeps from agents, to no project while those are kept from agents, or to a work or school account kept from agents: left out on purpose. Do not guess what they are; ask the person if it matters.)")
62    })
63}
64
65/// A thing the agent asked for by its address, kept from agents.
66pub fn kept(what: &str) -> String {
67    format!(
68        "{what} is kept from agents: it belongs to a project the person has not opened to agents, to no project while things outside projects are closed to them, or to a work or school account kept from agents. Sioul neither reads nor writes it for an agent. The person opens a project on its page in Sioul, or in Settings ▸ AI agents, where work and school accounts are opened too."
69    )
70}
71
72/// A project named by the agent, closed to agents.
73pub fn closed_project(id: &str) -> String {
74    format!("The project “{}” is closed to agents: Sioul neither reads it nor writes into it for an agent. The person opens it on its page in Sioul, or in Settings ▸ AI agents.", crate::one_line(id))
75}
76
77/// A new thing in no project, while those are closed to agents.
78pub const OUTSIDE_CLOSED: &str = "Things in no project are closed to agents (Settings ▸ AI agents, “Things in no project”): Sioul writes nothing outside a project for an agent. Give a project open to agents (list_projects lists them), or ask the person.";
79
80/// Whether a message reaches the agent, its whole text read when its file is known.
81pub fn allows_mail(consent: &Consent, world: &World, uri: &str) -> bool {
82    let id = links::id_of(&world.canonical(uri));
83    let card = world.mail.get(uri).and_then(|m| sioul_core::maildir::read_one(&m.path));
84    consent.allows_mail(Some(&id), card.as_ref())
85}
86
87/// Whether a thing reaches the agent, by its address; a message read whole.
88pub fn allows(consent: &Consent, world: &World, uri: &str) -> bool {
89    let uri = world.canonical(uri);
90    match links::kind_of(&uri) {
91        Kind::Mail => allows_mail(consent, world, &uri),
92        _ => consent.allows(&uri),
93    }
94}
95
96/// Tasks and projects as an agent may see them in a plan: the closed ones
97/// stay, so that the plan is the person's, but with nothing of theirs (a
98/// title, notes, a place, ties), to be left out wherever they are listed.
99pub fn redact(loaded: &mut Loaded, consent: &Consent) {
100    for task in loaded.tasks.iter_mut().filter(|t| !consent.allows(&links::task_uri(&t.uid))) {
101        *task = Task { title: KEPT.into(), notes: String::new(), location: String::new(), categories: Vec::new(), links: Vec::new(), contacts: Vec::new(), ..task.clone() };
102    }
103    for project in loaded.projects.iter_mut().filter(|c| !consent.is_open(&c.id)) {
104        project.title = KEPT.into();
105        project.client = None;
106        project.files.clear();
107        project.routes.clear();
108    }
109}