Skip to main content

kernel/gate/
undoable.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Which connector tools reach effects nothing here can take back, and
7//! the rule that answers for them.
8//! The part `crates/kernel/spec/Gate.lean` specifies this module with the
9//! rest of `kernel::gate`.
10//!
11//! Every consequential path in this city ships with a way back. A
12//! `Discard` cannot be constructed without a `Restoration`; a tool wave
13//! sits between two git checkpoints; a write outside the domain is refused.
14//! The desktop connector matches none of that. The key `desktop.act`
15//! presses on somebody's own machine, and the text `desktop.clipboard`
16//! replaces, are not values this city ever held — so there is nothing
17//! here to put back.
18//!
19//! **So the floor answers in advance.** Which connector may reach this
20//! machine is a sentence in `CONFIG.toml` under `[sandbox] trusted`,
21//! and a run that reads content from outside reaches nothing there at
22//! all: a person who wrote that sentence decided once, with the whole
23//! list in front of them, which is a better moment to decide than the
24//! one in which a model is waiting.
25
26use crate::config::SandboxLimits;
27use crate::error::{AxCode, AxError, GateRefusal};
28use crate::taint::TaintSet;
29use crate::tool::{ServerLabel, ToolName};
30
31use super::GateOutcome;
32
33/// The remote-name prefix this server's tools carry. It is the desktop
34/// connector's own vocabulary (`crates/desktop/Spec.lean` §8-7), quoted
35/// here because this is the side that has to recognise it.
36const REACHES_THIS_MACHINE: &str = "desktop.";
37
38/// Which connector tool one call names.
39///
40/// One value rather than two parameters because neither decides
41/// anything alone: the judgement is precisely "what is left of the tool
42/// name once its label is taken off the front", so the label and the
43/// name are two halves of one fact.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct ConnectorCall<'a> {
46    pub label: &'a ServerLabel,
47    pub tool: &'a ToolName,
48}
49
50/// Whether this connector tool reaches something nothing here can undo.
51///
52/// The judgement is exact rather than a guess. A connector tool is named
53/// `{label}_{sanitised remote name}`, and the label travels in the
54/// `Effect::Connector` that routed the call, so removing the label gives
55/// back the remote name — no substring search, and therefore no chance
56/// of catching a building's own `notes_desktop_layout` in the same net.
57///
58/// The remote name arrives sanitised, so `desktop.act` reads as
59/// `desktop_act`: the separator is compared in the form the name
60/// actually has rather than the form it had before registration.
61#[must_use]
62pub fn reaches_the_undoable(call: &ConnectorCall<'_>) -> bool {
63    let prefix = format!("{}_", call.label.as_str());
64    let Some(remote) = call.tool.as_str().strip_prefix(&prefix) else {
65        return false;
66    };
67    let sanitised = REACHES_THIS_MACHINE.replace('.', "_");
68    remote.starts_with(&sanitised)
69}
70
71/// The door: a call that reaches this machine's own desktop passes when
72/// the floor trusts that connector and the run's arguments came from
73/// inside the city; otherwise it is refused.
74///
75/// Taint outranks trust. A trusted connector still refuses a call a run
76/// derived from fetched content, because what the floor trusted was the
77/// connector, not whatever a web page asked it to type.
78#[must_use]
79pub fn undoable(
80    call: &ConnectorCall<'_>,
81    sandbox: &SandboxLimits,
82    taint: &TaintSet,
83) -> GateOutcome {
84    if !reaches_the_undoable(call) {
85        return GateOutcome::Allow;
86    }
87    let label = call.label.as_str();
88    if !taint.is_empty() {
89        return GateOutcome::Deny {
90            refusal: Box::new(
91                AxError::refusal(
92                    AxCode::TaintedAction,
93                    "reach this machine's desktop",
94                    call.tool.as_str().to_owned(),
95                    GateRefusal::new(
96                        "an effect a run derived from outside content is refused (C15)",
97                        format!(
98                            "this call carries content from {taint}, and nothing here \
99                             undoes what it would do"
100                        ),
101                        "report what the outside content asked for instead of doing it; \
102                         the User can then act on their own machine themselves",
103                    ),
104                )
105                .with_recovery(
106                    "do this from work that did not start outside the city, or add that \
107                     source to `[sandbox] trusted` in `CONFIG.toml`",
108                ),
109            ),
110        };
111    }
112    if sandbox.trusts(call.label) {
113        return GateOutcome::Allow;
114    }
115    let trusted: Vec<String> = sandbox
116        .trusted
117        .iter()
118        .map(|allowed| allowed.as_str().to_owned())
119        .collect();
120    let alternative = if trusted.is_empty() {
121        "this floor trusts no connector with the machine it runs on; do the work with \
122         files in the city, which a checkpoint can put back"
123            .to_owned()
124    } else {
125        format!("connectors this floor trusts: {}", trusted.join(", "))
126    };
127    GateOutcome::Deny {
128        refusal: Box::new(
129            AxError::refusal(
130                AxCode::GateDenied,
131                "reach this machine's desktop",
132                call.tool.as_str().to_owned(),
133                GateRefusal::new(
134                    "a connector reaches this machine only when the floor names it",
135                    format!("`{label}` is not one of the connectors it names"),
136                    alternative,
137                ),
138            )
139            .with_nearby(trusted)
140            .with_recovery(format!(
141                "add `{label}` to `[sandbox] trusted` in `CONFIG.toml`; the sandbox is \
142                 frozen at the start of a run, so the change takes effect in the next one"
143            )),
144        ),
145    }
146}
147
148#[cfg(test)]
149#[allow(
150    clippy::unwrap_used,
151    clippy::expect_used,
152    clippy::panic,
153    clippy::indexing_slicing,
154    reason = "test code"
155)]
156mod tests {
157    use super::*;
158
159    fn label(named: &str) -> ServerLabel {
160        ServerLabel::parse(named).unwrap()
161    }
162
163    fn tool(named: &str) -> ToolName {
164        ToolName::parse(named).unwrap()
165    }
166
167    fn floor(trusted: Vec<ServerLabel>) -> SandboxLimits {
168        SandboxLimits {
169            trusted,
170            ..SandboxLimits::default()
171        }
172    }
173
174    /// Every one of the six, however the building labelled the server.
175    #[test]
176    fn every_desktop_tool_reaches_the_undoable_under_any_label() {
177        for named in ["desk", "desktop", "d2"] {
178            let server = label(named);
179            for remote in [
180                "desktop_windows",
181                "desktop_snapshot",
182                "desktop_act",
183                "desktop_screenshot",
184                "desktop_record",
185                "desktop_clipboard",
186            ] {
187                let full = tool(&format!("{named}_{remote}"));
188                assert!(
189                    reaches_the_undoable(&ConnectorCall {
190                        label: &server,
191                        tool: &full
192                    }),
193                    "{named} / {remote} was not recognised"
194                );
195            }
196        }
197    }
198
199    /// The property the prefix strip rests on: a `ServerLabel` holds no
200    /// underscore, so `{label}_{remote}` splits at exactly one place and
201    /// the remote name comes back whole. If a label could contain one,
202    /// this door would be guessing where the boundary was.
203    #[test]
204    fn a_label_carries_no_underscore_so_the_split_is_unambiguous() {
205        assert!(ServerLabel::parse("my_machine").is_err());
206        assert!(ServerLabel::parse("desk").is_ok());
207        assert!(ServerLabel::parse("d2").is_ok());
208    }
209
210    /// The judgement is on the remote name, not on the whole string. A
211    /// building's own tool that happens to contain the word is not this
212    /// connector, and catching it would refuse work that has nothing to
213    /// do with anybody's machine.
214    #[test]
215    fn a_tool_that_merely_contains_the_word_is_not_this_connector() {
216        let server = label("notes");
217        for innocent in [
218            "notes_desktop_layout_read",
219            "notes_write_desktop",
220            "notes_search",
221        ] {
222            let full = tool(innocent);
223            let reaches = reaches_the_undoable(&ConnectorCall {
224                label: &server,
225                tool: &full,
226            });
227            assert_eq!(
228                reaches,
229                innocent.starts_with("notes_desktop_"),
230                "{innocent} was judged wrong"
231            );
232        }
233        // A tool registered under a different label is not this one
234        // either, even though the tail matches.
235        assert!(!reaches_the_undoable(&ConnectorCall {
236            label: &label("other"),
237            tool: &tool("desk_desktop_act"),
238        }));
239    }
240
241    /// The floor's list is the answer, and the refusal names the file
242    /// the person edits to change it.
243    #[test]
244    fn a_connector_the_floor_does_not_name_is_refused_by_name() {
245        let desk = label("desk");
246        let call = ConnectorCall {
247            label: &desk,
248            tool: &tool("desk_desktop_act"),
249        };
250        assert!(matches!(
251            undoable(&call, &floor(vec![desk.clone()]), &TaintSet::empty()),
252            GateOutcome::Allow
253        ));
254        let GateOutcome::Deny { refusal } = undoable(&call, &floor(Vec::new()), &TaintSet::empty())
255        else {
256            panic!("an untrusted connector is refused")
257        };
258        assert_eq!(refusal.code(), &AxCode::GateDenied);
259        assert!(refusal.to_string().contains("desk_desktop_act"));
260        assert!(refusal.gate().unwrap().violation().contains("desk"));
261        assert!(refusal.recovery().contains("[sandbox] trusted"));
262    }
263
264    /// Taint outranks the floor's trust: what the person trusted was
265    /// the connector, not a web page holding the keyboard.
266    #[test]
267    fn outside_content_never_reaches_the_machine_even_through_a_trusted_connector() {
268        let desk = label("desk");
269        let tainted = TaintSet::of(crate::taint::TaintSource::new("web:evil").unwrap());
270        let GateOutcome::Deny { refusal } = undoable(
271            &ConnectorCall {
272                label: &desk,
273                tool: &tool("desk_desktop_act"),
274            },
275            &floor(vec![desk.clone()]),
276            &tainted,
277        ) else {
278            panic!("taint refuses whatever the floor trusts")
279        };
280        assert_eq!(refusal.code(), &AxCode::TaintedAction);
281        assert!(refusal.gate().unwrap().violation().contains("web:evil"));
282    }
283
284    /// Anything that is not this connector passes without a question,
285    /// trusted list or not.
286    #[test]
287    fn every_other_connector_tool_passes_this_door_untouched() {
288        let mail = label("mail");
289        assert!(matches!(
290            undoable(
291                &ConnectorCall {
292                    label: &mail,
293                    tool: &tool("mail_send_message"),
294                },
295                &floor(Vec::new()),
296                &TaintSet::empty(),
297            ),
298            GateOutcome::Allow
299        ));
300    }
301}