1use crate::config::SandboxLimits;
27use crate::error::{AxCode, AxError, GateRefusal};
28use crate::taint::TaintSet;
29use crate::tool::{ServerLabel, ToolName};
30
31use super::GateOutcome;
32
33const REACHES_THIS_MACHINE: &str = "desktop.";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct ConnectorCall<'a> {
46 pub label: &'a ServerLabel,
47 pub tool: &'a ToolName,
48}
49
50#[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#[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 #[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 #[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 #[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 assert!(!reaches_the_undoable(&ConnectorCall {
236 label: &label("other"),
237 tool: &tool("desk_desktop_act"),
238 }));
239 }
240
241 #[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 #[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 #[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}