Skip to main content

runtime/bench/
admit.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//! The doors: which gate one call's declared Effect names, and what
7//! that gate's verdict means to the bench.
8
9use super::*;
10
11impl ToolBench {
12    /// Hands the bench the egress list the building wrote, frozen with
13    /// the run (`crates/city/spec/Policy.lean` D27). A browser call to a
14    /// public host outside it is refused before any byte leaves; the
15    /// judgement compares against this short list, so it adds no read,
16    /// no lookup and no round trip to the call.
17    #[must_use]
18    pub fn limit_egress(mut self, list: kernel::EgressAllowlist) -> ToolBench {
19        self.egress = Some(list);
20        self
21    }
22
23    /// The door this call's declared Effect names.
24    ///
25    /// `None` means the door is open and the tool may run; `Some` means
26    /// the door answered for this call and the tool does not run. The
27    /// answer is the bench's, not the gate's: a refusal flows back as a
28    /// tool result rather than ending the turn, and an asking door's
29    /// question reaches the model the same way.
30    ///
31    /// `subject` is what the tool read off its own arguments: the
32    /// tool's grammar answers and this function only matches, so no
33    /// argument name is spelled twice.
34    ///
35    /// # Errors
36    /// Refuses a call that declares an effect this bench does not route,
37    /// a subject its effect does not read, a spawn or a rule change on a
38    /// bench built without a job, and arguments that will not serialise
39    /// for the secret scan.
40    pub(super) fn admit(
41        &mut self,
42        call: &ToolCall,
43        name: &str,
44        effect: &Effect,
45        subject: &GateSubject,
46    ) -> Result<Option<BenchOutcome>, AxError> {
47        match effect {
48            Effect::Read => {}
49            // The tool declares an area, and a call may name a narrower
50            // one. Both are asked the same question, and both must pass:
51            // the declaration is what the tool promised to reach, and
52            // the call's own area is what it now reaches for.
53            Effect::Write { domain: area } => {
54                let verdict = kernel::gate::reach(&self.domain, area, &self.taint);
55                if let Some(answered) = self.settled(verdict) {
56                    return Ok(Some(answered));
57                }
58                let named = match subject {
59                    GateSubject::Area(at) | GateSubject::Room(at) => Some(at),
60                    GateSubject::Scope(_) | GateSubject::Host(_) | GateSubject::None => None,
61                };
62                if let Some(at) = named {
63                    let verdict = kernel::gate::reach(&self.domain, at, &self.taint);
64                    if let Some(answered) = self.settled(verdict) {
65                        return Ok(Some(answered));
66                    }
67                }
68            }
69            Effect::Connector { label } => {
70                // Same door, same scan; only the target differs. A
71                // connector's destination is its registration's, so
72                // there is nothing for the call to name and nothing for
73                // a model to get wrong.
74                let spans = kernel::secret::scan(&scanned(call, "scan connector args")?);
75                let verdict = kernel::gate::egress(
76                    &spans,
77                    &EgressTarget::Connector {
78                        label: label.clone(),
79                    },
80                    self.prior_public_egress,
81                );
82                if let Some(answered) = self.crossed(verdict) {
83                    return Ok(Some(answered));
84                }
85                // A second door, behind the first, answering a
86                // different question. The egress door asked whether
87                // these bytes may leave; this one asks whether what
88                // happens at the other end can be taken back. The
89                // desktop connector is the first thing here for which
90                // the answer can be no: a key pressed on somebody's own
91                // machine has no restoration, so the floor's `[sandbox]`
92                // decides, and content that came in from outside is
93                // refused whatever the floor says.
94                let called = ToolName::parse(name)?;
95                let reaching = kernel::ConnectorCall {
96                    label,
97                    tool: &called,
98                };
99                if kernel::gate::reaches_the_undoable(&reaching) {
100                    let verdict = kernel::gate::undoable(&reaching, &self.sandbox, &self.taint);
101                    if let Some(answered) = self.settled(verdict) {
102                        return Ok(Some(answered));
103                    }
104                }
105            }
106            Effect::Egress => {
107                // A call that names no destination keeps its bytes on
108                // this machine, and the scan still runs: what a model
109                // typed into a page would leave through it.
110                let target = bytes_target(name, subject, "send bytes out")?;
111                if let Some(answered) = self.listed(&target) {
112                    return Ok(Some(answered));
113                }
114                let spans = kernel::secret::scan(&scanned(call, "scan egress args")?);
115                let verdict = kernel::gate::egress(&spans, &target, self.prior_public_egress);
116                if let Some(answered) = self.crossed(verdict) {
117                    return Ok(Some(answered));
118                }
119            }
120            Effect::AttachUserBrowser { address } => {
121                // The one door that asks. The address is the tool's
122                // registration's, not the call's: every call attaches to
123                // the browser the person declared, and a model filling
124                // in an address would be inventing a fact the city
125                // already holds. `None` is a building that enabled the
126                // tool and has not been told where the browser answers,
127                // which is the state the person's next action resolves.
128                let endpoint = address.as_deref().map(kernel::gate::target_of);
129                let verdict = kernel::gate::attach(endpoint.as_ref());
130                if let Some(answered) = self.settled(verdict) {
131                    return Ok(Some(answered));
132                }
133                // The bytes are still scanned: what gets typed into a
134                // page is the credential this door can see, and a call
135                // that names no page is the attachment itself.
136                let page = bytes_target(name, subject, "drive a browser")?;
137                if let Some(answered) = self.listed(&page) {
138                    return Ok(Some(answered));
139                }
140                let spans = kernel::secret::scan(&scanned(call, "scan browser args")?);
141                let verdict = kernel::gate::egress(&spans, &page, self.prior_public_egress);
142                if let Some(answered) = self.crossed(verdict) {
143                    return Ok(Some(answered));
144                }
145            }
146            Effect::Spawn => {
147                // No door here. How deep work may be handed down is
148                // a type rather than a question, and the depth a
149                // spawn would reach is known where the parent run
150                // is: `gate::spawn` runs at the dispatch site, with
151                // the parent's `Depth` in hand.
152            }
153            Effect::Govern => {
154                // A run does not rewrite the rules it is judged by. The
155                // rules are in `CONFIG.toml` and in the building's own
156                // `RULES.toml`, where a person edits them; a door that
157                // asked instead would be a door whose default answer
158                // gets clicked through.
159                let scope = match subject {
160                    GateSubject::Scope(scope) => scope.clone(),
161                    // A governance call names the scope it would rewrite;
162                    // one that names none is refused the same way a
163                    // subject the effect cannot read is.
164                    GateSubject::None => {
165                        return Err(subject_not_for(name, subject, "change what governs"));
166                    }
167                    other
168                    @ (GateSubject::Area(_) | GateSubject::Room(_) | GateSubject::Host(_)) => {
169                        return Err(subject_not_for(name, other, "change what governs"));
170                    }
171                };
172                return Err(AxError::failure(
173                    AxCode::GateDenied,
174                    "invoke tool",
175                    format!(
176                        "`{name}` declares Govern, and a run may not change what governs it \
177                         ({scope})"
178                    ),
179                )
180                .with_recovery(
181                    "edit the scope's `CONFIG.toml`, or the building's `RULES.toml`, and \
182                     dispatch again",
183                ));
184            }
185            Effect::Spend => {
186                // No tool in this build declares Spend; the door is
187                // wired so the first one meets it.
188                return Err(AxError::failure(
189                    AxCode::ToolUnavailable,
190                    "invoke tool",
191                    format!("`{name}` declares Spend, which no tool in this build carries"),
192                )
193                .with_recovery(
194                    "do this work with a tool that spends nothing; no tool in this build \
195                     can move money",
196                ));
197            }
198        }
199        Ok(None)
200    }
201
202    /// What one gate's verdict means to this bench.
203    ///
204    /// Three answers, because a door has three: an Allow lets the call
205    /// through, a Deny is the outcome, and an Ask reaches the model as
206    /// the door's own question - `E_APPROVAL_PENDING` with the recovery
207    /// sentence naming what only the person can do. A pending question
208    /// ends the call, not the turn.
209    pub(super) fn settled(&self, outcome: GateOutcome) -> Option<BenchOutcome> {
210        match outcome {
211            GateOutcome::Allow => None,
212            GateOutcome::Deny { refusal } => Some(BenchOutcome::Refused { refusal }),
213            GateOutcome::Ask { question } => Some(BenchOutcome::Refused { refusal: question }),
214        }
215    }
216
217    /// The building's egress list, asked about the host one call names
218    /// when the building wrote a list (`crates/city/spec/Policy.lean`
219    /// D27). It stands before the secret scan: a host the building does
220    /// not reach owes no answer about which bytes may go there.
221    fn listed(&self, target: &EgressTarget) -> Option<BenchOutcome> {
222        let list = self.egress.as_ref()?;
223        match kernel::gate::egress_target(list, target) {
224            EgressOutcome::Allow { .. } => None,
225            EgressOutcome::Deny { refusal } => Some(BenchOutcome::Refused { refusal }),
226        }
227    }
228
229    /// What one egress verdict means to this bench.
230    ///
231    /// The first public egress is remembered here, once, for both doors
232    /// that can reach outside: a connector's registered destination and
233    /// a call's own host.
234    fn crossed(&mut self, outcome: EgressOutcome) -> Option<BenchOutcome> {
235        match outcome {
236            EgressOutcome::Allow {
237                first_public_egress,
238            } => {
239                if first_public_egress {
240                    self.prior_public_egress = true;
241                }
242                None
243            }
244            EgressOutcome::Deny { refusal } => Some(BenchOutcome::Refused { refusal }),
245        }
246    }
247}
248
249/// The destination one call's bytes go to, for the scan both outward
250/// doors run.
251///
252/// A call whose grammar names no destination keeps its bytes on this
253/// machine, so its target is loopback: the scan runs instead of being
254/// skipped, and no host the call did not name is claimed. Any other
255/// answer is a tool whose `subject` and `effect` disagree, and is
256/// refused rather than judged.
257fn bytes_target(name: &str, subject: &GateSubject, doing: &str) -> Result<EgressTarget, AxError> {
258    match subject {
259        GateSubject::Host(host) => Ok(kernel::gate::target_of(host)),
260        GateSubject::None => Ok(EgressTarget::Loopback),
261        other @ (GateSubject::Area(_) | GateSubject::Room(_) | GateSubject::Scope(_)) => {
262            Err(subject_not_for(name, other, doing))
263        }
264    }
265}
266
267/// A tool answered with a subject its effect does not read. Refused
268/// rather than judged by something else: a tool whose `subject` and
269/// `effect` disagree is a wiring defect, and the next reader needs to
270/// see it here instead of a gate judging the wrong thing. A subject a
271/// call needed and never named takes the same refusal.
272fn subject_not_for(name: &str, subject: &GateSubject, doing: &str) -> AxError {
273    AxError::failure(
274        AxCode::InvalidArgs,
275        "invoke tool",
276        format!(
277            "`{name}` declares {doing} and its `subject` answered {}",
278            spell(subject)
279        ),
280    )
281    .with_recovery(
282        "report this against the tool: `Tool::subject` and `ToolMeta::effect` disagree, \
283         and the bench reads the subject the tool answers with",
284    )
285}
286
287fn spell(subject: &GateSubject) -> &'static str {
288    match subject {
289        GateSubject::Area(_) => "an area",
290        GateSubject::Room(_) => "a room",
291        GateSubject::Scope(_) => "a scope",
292        GateSubject::Host(_) => "a host",
293        GateSubject::None => "no subject",
294    }
295}
296
297/// One call's arguments as the bytes the secret scan reads.
298///
299/// `doing` names which outward door asked, so a failure to serialise
300/// says where it happened.
301///
302/// # Errors
303/// Refuses arguments that will not serialise: a call this bench cannot
304/// judge rather than one it may let through.
305fn scanned(call: &ToolCall, doing: &'static str) -> Result<Vec<u8>, AxError> {
306    serde_json::to_vec(&call.args).map_err(|err| {
307        AxError::failure(AxCode::InvalidArgs, doing, err.to_string()).with_recovery(
308            "call the tool again with arguments made of strings and whole numbers, \
309                 which is all this city's payloads carry",
310        )
311    })
312}
313
314#[cfg(test)]
315#[allow(
316    clippy::unwrap_used,
317    clippy::expect_used,
318    clippy::panic,
319    reason = "test code"
320)]
321mod tests {
322    use super::*;
323    use kernel::{RunId, Seq};
324
325    struct Stub(kernel::ToolMeta, GateSubject);
326
327    fn stub(name: &str, effect: Effect, answer: GateSubject) -> Box<dyn Tool> {
328        Box::new(Stub(
329            kernel::ToolMeta {
330                name: kernel::ToolName::parse(name).unwrap(),
331                disclosure: "a stub the door tests drive".to_owned(),
332                params: kernel::Payload::empty(),
333                effect,
334                cost_tier: kernel::CostTier::Free,
335                timeout: None,
336                render: kernel::RenderIntent::Generic,
337                temporal: kernel::Temporal::Timeless,
338            },
339            answer,
340        ))
341    }
342
343    impl Tool for Stub {
344        fn meta(&self) -> &kernel::ToolMeta {
345            &self.0
346        }
347
348        fn invoke(&self, _call: &ToolCall) -> Result<ToolOutcome, AxError> {
349            Ok(ToolOutcome {
350                result: kernel::Payload::empty(),
351                attachments: Vec::new(),
352            })
353        }
354
355        fn subject(&self, _call: &ToolCall) -> Result<GateSubject, AxError> {
356            Ok(self.1.clone())
357        }
358    }
359
360    /// A governance tool whose `read` operation only reads: the shape
361    /// of `rules` and `city`, without their files.
362    struct Governs(kernel::ToolMeta);
363
364    impl Governs {
365        fn op(call: &ToolCall) -> Option<&str> {
366            call.args.as_map().get("op").and_then(Value::as_str)
367        }
368    }
369
370    impl Tool for Governs {
371        fn meta(&self) -> &kernel::ToolMeta {
372            &self.0
373        }
374
375        fn invoke(&self, _call: &ToolCall) -> Result<ToolOutcome, AxError> {
376            Ok(ToolOutcome {
377                result: kernel::Payload::empty(),
378                attachments: Vec::new(),
379            })
380        }
381
382        fn subject(&self, _call: &ToolCall) -> Result<GateSubject, AxError> {
383            Ok(GateSubject::Scope("building:lab".to_owned()))
384        }
385
386        fn effect_of(&self, call: &ToolCall) -> Result<Effect, AxError> {
387            Ok(match Self::op(call) {
388                Some("read") => Effect::Read,
389                Some(_) | None => Effect::Govern,
390            })
391        }
392    }
393
394    fn op_call(op: &str) -> ToolCall {
395        let mut args = serde_json::Map::new();
396        args.insert("op".to_owned(), Value::String(op.to_owned()));
397        call("ruler", kernel::Payload::new(args).unwrap())
398    }
399
400    /// The bench routes on the call's effect, not the tool's: a read of
401    /// the rules runs inside a run, a rewrite is still refused
402    /// (`crates/kernel/spec/Gate.lean` D26).
403    #[test]
404    fn a_govern_tool_read_runs_in_a_run_and_its_rewrite_is_still_refused() {
405        let meta = stub("ruler", Effect::Govern, GateSubject::None)
406            .meta()
407            .clone();
408        let mut bench = bench_with(Box::new(Governs(meta)));
409        let read = bench.invoke(&op_call("read"), &key(6), at()).unwrap();
410        assert!(matches!(read, BenchOutcome::Ran { .. }), "{read:?}");
411        let rewrite = bench
412            .invoke(&op_call("propose"), &key(7), at())
413            .unwrap_err();
414        assert_eq!(rewrite.code(), &AxCode::GateDenied);
415    }
416
417    fn bench_with(tool: Box<dyn Tool>) -> ToolBench {
418        let domain = WriteDomain::new(vec![Address::parse("work").unwrap()]).unwrap();
419        let mut bench = ToolBench::new(domain);
420        bench.register(tool).unwrap();
421        bench
422    }
423
424    fn call(tool: &str, args: kernel::Payload) -> ToolCall {
425        ToolCall {
426            id: "c-1".to_owned(),
427            name: kernel::ToolName::parse(tool).unwrap(),
428            args,
429        }
430    }
431
432    fn key(n: u64) -> IdemKey {
433        IdemKey::derive(&RunId::from_bytes([1u8; 16]), Seq::new(n), b"door")
434    }
435
436    fn at() -> kernel::TimeMs {
437        kernel::TimeMs::new(1_700_000_000_000)
438    }
439
440    #[test]
441    fn an_egress_call_that_names_no_host_is_still_scanned() {
442        let mut bench = bench_with(stub("webbish", Effect::Egress, GateSubject::None));
443        let secret = format!("sk-ant-{}", "a1B2c3D4e5".repeat(9));
444        let mut args = serde_json::Map::new();
445        args.insert("text".to_owned(), Value::String(secret));
446        let answered = bench
447            .invoke(
448                &call("webbish", kernel::Payload::new(args).unwrap()),
449                &key(1),
450                at(),
451            )
452            .unwrap();
453        assert!(matches!(answered, BenchOutcome::Refused { .. }));
454    }
455
456    #[test]
457    fn an_egress_call_that_names_no_host_still_runs_when_the_bytes_are_clean() {
458        let mut bench = bench_with(stub("webbish", Effect::Egress, GateSubject::None));
459        let answered = bench
460            .invoke(&call("webbish", kernel::Payload::empty()), &key(2), at())
461            .unwrap();
462        assert!(matches!(answered, BenchOutcome::Ran { .. }));
463    }
464
465    fn listing(domains: &[&str]) -> kernel::EgressAllowlist {
466        kernel::EgressAllowlist::new(domains.iter().map(|d| (*d).to_owned()).collect())
467    }
468
469    fn page(host: &str) -> GateSubject {
470        GateSubject::Host(host.to_owned())
471    }
472
473    /// The building's list decides which public hosts a browser call
474    /// reaches, its subdomains included; a building that wrote no list
475    /// keeps every host (`crates/city/spec/Policy.lean` D27).
476    #[test]
477    fn a_browser_call_reaches_only_the_hosts_the_building_lists() {
478        let outcomes = [
479            ("pastebin.test", Some(listing(&["example.com"]))),
480            ("docs.example.com", Some(listing(&["example.com"]))),
481            ("example.com", Some(listing(&[]))),
482            ("pastebin.test", None),
483            ("127.0.0.1", Some(listing(&[]))),
484        ]
485        .into_iter()
486        .enumerate()
487        .map(|(n, (host, list))| {
488            let bench = bench_with(stub("webbish", Effect::Egress, page(host)));
489            let mut bench = match list {
490                Some(list) => bench.limit_egress(list),
491                None => bench,
492            };
493            let seq = u64::try_from(n).unwrap() + 10;
494            match bench.invoke(&call("webbish", kernel::Payload::empty()), &key(seq), at()) {
495                Ok(BenchOutcome::Ran { .. }) => "ran".to_owned(),
496                Ok(BenchOutcome::Refused { refusal }) => refusal.code().to_string(),
497                Ok(BenchOutcome::Duplicate { .. }) => "duplicate".to_owned(),
498                Err(failed) => format!("failed {}", failed.code()),
499            }
500        })
501        .collect::<Vec<_>>();
502        assert_eq!(
503            outcomes,
504            ["E_GATE_DENIED", "ran", "E_GATE_DENIED", "ran", "ran"]
505        );
506    }
507
508    /// The person's browser is held to the same list for the page a call
509    /// names, after the door that asks whether it may be attached at all.
510    #[test]
511    fn the_persons_browser_is_held_to_the_same_list() {
512        let effect = Effect::AttachUserBrowser {
513            address: Some("127.0.0.1".to_owned()),
514        };
515        let mut bench = bench_with(stub("drives", effect, page("pastebin.test")))
516            .limit_egress(listing(&["example.com"]));
517        let answered = bench
518            .invoke(&call("drives", kernel::Payload::empty()), &key(30), at())
519            .unwrap();
520        let BenchOutcome::Refused { refusal } = answered else {
521            panic!("expected a refusal, got {answered:?}");
522        };
523        assert_eq!(
524            (refusal.code(), refusal.subject()),
525            (&AxCode::GateDenied, "pastebin.test")
526        );
527    }
528
529    #[test]
530    fn an_egress_call_that_answers_with_an_area_is_refused_as_a_wiring_defect() {
531        let area = Address::parse("work/room").unwrap();
532        let mut bench = bench_with(stub("webbish", Effect::Egress, GateSubject::Area(area)));
533        let refused = bench.invoke(&call("webbish", kernel::Payload::empty()), &key(3), at());
534        assert!(refused.is_err());
535    }
536
537    #[test]
538    fn a_govern_call_that_names_no_scope_is_refused_as_a_tool_that_named_none() {
539        let mut bench = bench_with(stub("ruler", Effect::Govern, GateSubject::None));
540        let refused = bench
541            .invoke(&call("ruler", kernel::Payload::empty()), &key(4), at())
542            .unwrap_err();
543        assert!(refused.subject().contains("no subject"));
544    }
545
546    #[test]
547    fn a_govern_refusal_tells_the_model_its_recovery_without_a_run_of_spaces() {
548        let scope = GateSubject::Scope("work".to_owned());
549        let mut bench = bench_with(stub("ruler", Effect::Govern, scope));
550        let refused = bench
551            .invoke(&call("ruler", kernel::Payload::empty()), &key(5), at())
552            .unwrap_err();
553        assert_eq!(
554            refused.recovery(),
555            "edit the scope's `CONFIG.toml`, or the building's `RULES.toml`, and dispatch again"
556        );
557    }
558}