sprawling-runtime 0.0.10

One run of a sprawling resident, from dispatch to freeze: turns, tools, replay and the sandbox that executes code.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! The doors: which gate one call's declared Effect names, and what
//! that gate's verdict means to the bench.

use super::*;

impl ToolBench {
    /// The door this call's declared Effect names.
    ///
    /// `None` means the door is open and the tool may run; `Some` means
    /// the door answered for this call and the tool does not run. The
    /// answer is the bench's, not the gate's: a refusal flows back as a
    /// tool result rather than ending the turn, and an asking door's
    /// question reaches the model the same way.
    ///
    /// `subject` is what the tool read off its own arguments: the
    /// tool's grammar answers and this function only matches, so no
    /// argument name is spelled twice.
    ///
    /// # Errors
    /// Refuses a call that declares an effect this bench does not route,
    /// a subject its effect does not read, a spawn or a rule change on a
    /// bench built without a job, and arguments that will not serialise
    /// for the secret scan.
    pub(super) fn admit(
        &mut self,
        call: &ToolCall,
        name: &str,
        effect: &Effect,
        subject: &GateSubject,
    ) -> Result<Option<BenchOutcome>, AxError> {
        match effect {
            Effect::Read => {}
            // The tool declares an area, and a call may name a narrower
            // one. Both are asked the same question, and both must pass:
            // the declaration is what the tool promised to reach, and
            // the call's own area is what it now reaches for.
            Effect::Write { domain: area } => {
                let verdict = kernel::gate::reach(&self.domain, area, &self.taint);
                if let Some(answered) = self.settled(verdict) {
                    return Ok(Some(answered));
                }
                let named = match subject {
                    GateSubject::Area(at) | GateSubject::Room(at) => Some(at),
                    GateSubject::Scope(_) | GateSubject::Host(_) | GateSubject::None => None,
                };
                if let Some(at) = named {
                    let verdict = kernel::gate::reach(&self.domain, at, &self.taint);
                    if let Some(answered) = self.settled(verdict) {
                        return Ok(Some(answered));
                    }
                }
            }
            Effect::Connector { label } => {
                // Same door, same scan; only the target differs. A
                // connector's destination is its registration's, so
                // there is nothing for the call to name and nothing for
                // a model to get wrong.
                let spans = kernel::secret::scan(&scanned(call, "scan connector args")?);
                let verdict = kernel::gate::egress(
                    &spans,
                    &EgressTarget::Connector {
                        label: label.clone(),
                    },
                    self.prior_public_egress,
                );
                if let Some(answered) = self.crossed(verdict) {
                    return Ok(Some(answered));
                }
                // A second door, behind the first, answering a
                // different question. The egress door asked whether
                // these bytes may leave; this one asks whether what
                // happens at the other end can be taken back. The
                // desktop connector is the first thing here for which
                // the answer can be no: a key pressed on somebody's own
                // machine has no restoration, so the floor's `[sandbox]`
                // decides, and content that came in from outside is
                // refused whatever the floor says.
                let called = ToolName::parse(name)?;
                let reaching = kernel::ConnectorCall {
                    label,
                    tool: &called,
                };
                if kernel::gate::reaches_the_undoable(&reaching) {
                    let verdict = kernel::gate::undoable(&reaching, &self.sandbox, &self.taint);
                    if let Some(answered) = self.settled(verdict) {
                        return Ok(Some(answered));
                    }
                }
            }
            Effect::Egress => {
                // A call that names no destination keeps its bytes on
                // this machine, and the scan still runs: what a model
                // typed into a page would leave through it.
                let target = bytes_target(name, subject, "send bytes out")?;
                let spans = kernel::secret::scan(&scanned(call, "scan egress args")?);
                let verdict = kernel::gate::egress(&spans, &target, self.prior_public_egress);
                if let Some(answered) = self.crossed(verdict) {
                    return Ok(Some(answered));
                }
            }
            Effect::AttachUserBrowser { address } => {
                // The one door that asks. The address is the tool's
                // registration's, not the call's: every call attaches to
                // the browser the person declared, and a model filling
                // in an address would be inventing a fact the city
                // already holds. `None` is a building that enabled the
                // tool and has not been told where the browser answers,
                // which is the state the person's next action resolves.
                let endpoint = address.as_deref().map(kernel::gate::target_of);
                let verdict = kernel::gate::attach(endpoint.as_ref());
                if let Some(answered) = self.settled(verdict) {
                    return Ok(Some(answered));
                }
                // The bytes are still scanned: what gets typed into a
                // page is the credential this door can see, and a call
                // that names no page is the attachment itself.
                let page = bytes_target(name, subject, "drive a browser")?;
                let spans = kernel::secret::scan(&scanned(call, "scan browser args")?);
                let verdict = kernel::gate::egress(&spans, &page, self.prior_public_egress);
                if let Some(answered) = self.crossed(verdict) {
                    return Ok(Some(answered));
                }
            }
            Effect::Spawn => {
                // No door here. How deep work may be handed down is
                // a type rather than a question, and the depth a
                // spawn would reach is known where the parent run
                // is: `gate::spawn` runs at the dispatch site, with
                // the parent's `Depth` in hand.
            }
            Effect::Govern => {
                // A run does not rewrite the rules it is judged by. The
                // rules are in `CONFIG.toml` and in the building's own
                // `RULES.toml`, where a person edits them; a door that
                // asked instead would be a door whose default answer
                // gets clicked through.
                let scope = match subject {
                    GateSubject::Scope(scope) => scope.clone(),
                    // A governance call names the scope it would rewrite;
                    // one that names none is refused the same way a
                    // subject the effect cannot read is.
                    GateSubject::None => {
                        return Err(subject_not_for(name, subject, "change what governs"));
                    }
                    other
                    @ (GateSubject::Area(_) | GateSubject::Room(_) | GateSubject::Host(_)) => {
                        return Err(subject_not_for(name, other, "change what governs"));
                    }
                };
                return Err(AxError::failure(
                    AxCode::GateDenied,
                    "invoke tool",
                    format!(
                        "`{name}` declares Govern, and a run may not change what governs it \
                         ({scope})"
                    ),
                )
                .with_recovery(
                    "edit the scope's `CONFIG.toml`, or the building's `RULES.toml`, and \
                     dispatch again",
                ));
            }
            Effect::Spend => {
                // No tool in this build declares Spend; the door is
                // wired so the first one meets it.
                return Err(AxError::failure(
                    AxCode::ToolUnavailable,
                    "invoke tool",
                    format!("`{name}` declares Spend, which no tool in this build carries"),
                )
                .with_recovery(
                    "do this work with a tool that spends nothing; no tool in this build \
                     can move money",
                ));
            }
        }
        Ok(None)
    }

    /// What one gate's verdict means to this bench.
    ///
    /// Three answers, because a door has three: an Allow lets the call
    /// through, a Deny is the outcome, and an Ask reaches the model as
    /// the door's own question - `E_APPROVAL_PENDING` with the recovery
    /// sentence naming what only the person can do. A pending question
    /// ends the call, not the turn.
    pub(super) fn settled(&self, outcome: GateOutcome) -> Option<BenchOutcome> {
        match outcome {
            GateOutcome::Allow => None,
            GateOutcome::Deny { refusal } => Some(BenchOutcome::Refused { refusal }),
            GateOutcome::Ask { question } => Some(BenchOutcome::Refused { refusal: question }),
        }
    }

    /// What one egress verdict means to this bench.
    ///
    /// The first public egress is remembered here, once, for both doors
    /// that can reach outside: a connector's registered destination and
    /// a call's own host.
    fn crossed(&mut self, outcome: EgressOutcome) -> Option<BenchOutcome> {
        match outcome {
            EgressOutcome::Allow {
                first_public_egress,
            } => {
                if first_public_egress {
                    self.prior_public_egress = true;
                }
                None
            }
            EgressOutcome::Deny { refusal } => Some(BenchOutcome::Refused { refusal }),
        }
    }
}

/// The destination one call's bytes go to, for the scan both outward
/// doors run.
///
/// A call whose grammar names no destination keeps its bytes on this
/// machine, so its target is loopback: the scan runs instead of being
/// skipped, and no host the call did not name is claimed. Any other
/// answer is a tool whose `subject` and `effect` disagree, and is
/// refused rather than judged.
fn bytes_target(name: &str, subject: &GateSubject, doing: &str) -> Result<EgressTarget, AxError> {
    match subject {
        GateSubject::Host(host) => Ok(kernel::gate::target_of(host)),
        GateSubject::None => Ok(EgressTarget::Loopback),
        other @ (GateSubject::Area(_) | GateSubject::Room(_) | GateSubject::Scope(_)) => {
            Err(subject_not_for(name, other, doing))
        }
    }
}

/// A tool answered with a subject its effect does not read. Refused
/// rather than judged by something else: a tool whose `subject` and
/// `effect` disagree is a wiring defect, and the next reader needs to
/// see it here instead of a gate judging the wrong thing. A subject a
/// call needed and never named takes the same refusal.
fn subject_not_for(name: &str, subject: &GateSubject, doing: &str) -> AxError {
    AxError::failure(
        AxCode::InvalidArgs,
        "invoke tool",
        format!(
            "`{name}` declares {doing} and its `subject` answered {}",
            spell(subject)
        ),
    )
    .with_recovery(
        "report this against the tool: `Tool::subject` and `ToolMeta::effect` disagree, \
         and the bench reads the subject the tool answers with",
    )
}

fn spell(subject: &GateSubject) -> &'static str {
    match subject {
        GateSubject::Area(_) => "an area",
        GateSubject::Room(_) => "a room",
        GateSubject::Scope(_) => "a scope",
        GateSubject::Host(_) => "a host",
        GateSubject::None => "no subject",
    }
}

/// One call's arguments as the bytes the secret scan reads.
///
/// `doing` names which outward door asked, so a failure to serialise
/// says where it happened.
///
/// # Errors
/// Refuses arguments that will not serialise: a call this bench cannot
/// judge rather than one it may let through.
fn scanned(call: &ToolCall, doing: &'static str) -> Result<Vec<u8>, AxError> {
    serde_json::to_vec(&call.args).map_err(|err| {
        AxError::failure(AxCode::InvalidArgs, doing, err.to_string()).with_recovery(
            "call the tool again with arguments made of strings and whole numbers, \
                 which is all this city's payloads carry",
        )
    })
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    reason = "test code"
)]
mod tests {
    use super::*;
    use kernel::{RunId, Seq};

    struct Stub(kernel::ToolMeta, GateSubject);

    fn stub(name: &str, effect: Effect, answer: GateSubject) -> Box<dyn Tool> {
        Box::new(Stub(
            kernel::ToolMeta {
                name: kernel::ToolName::parse(name).unwrap(),
                disclosure: "a stub the door tests drive".to_owned(),
                params: kernel::Payload::empty(),
                effect,
                cost_tier: kernel::CostTier::Free,
                timeout: None,
                render: kernel::RenderIntent::Generic,
                temporal: kernel::Temporal::Timeless,
            },
            answer,
        ))
    }

    impl Tool for Stub {
        fn meta(&self) -> &kernel::ToolMeta {
            &self.0
        }

        fn invoke(&self, _call: &ToolCall) -> Result<ToolOutcome, AxError> {
            Ok(ToolOutcome {
                result: kernel::Payload::empty(),
                attachments: Vec::new(),
            })
        }

        fn subject(&self, _call: &ToolCall) -> Result<GateSubject, AxError> {
            Ok(self.1.clone())
        }
    }

    /// A governance tool whose `read` operation only reads: the shape
    /// of `rules` and `city`, without their files.
    struct Governs(kernel::ToolMeta);

    impl Governs {
        fn op(call: &ToolCall) -> Option<&str> {
            call.args.as_map().get("op").and_then(Value::as_str)
        }
    }

    impl Tool for Governs {
        fn meta(&self) -> &kernel::ToolMeta {
            &self.0
        }

        fn invoke(&self, _call: &ToolCall) -> Result<ToolOutcome, AxError> {
            Ok(ToolOutcome {
                result: kernel::Payload::empty(),
                attachments: Vec::new(),
            })
        }

        fn subject(&self, _call: &ToolCall) -> Result<GateSubject, AxError> {
            Ok(GateSubject::Scope("building:lab".to_owned()))
        }

        fn effect_of(&self, call: &ToolCall) -> Result<Effect, AxError> {
            Ok(match Self::op(call) {
                Some("read") => Effect::Read,
                Some(_) | None => Effect::Govern,
            })
        }
    }

    fn op_call(op: &str) -> ToolCall {
        let mut args = serde_json::Map::new();
        args.insert("op".to_owned(), Value::String(op.to_owned()));
        call("ruler", kernel::Payload::new(args).unwrap())
    }

    /// The bench routes on the call's effect, not the tool's: a read of
    /// the rules runs inside a run, a rewrite is still refused
    /// (`crates/kernel/spec/Gate.lean` D26).
    #[test]
    fn a_govern_tool_read_runs_in_a_run_and_its_rewrite_is_still_refused() {
        let meta = stub("ruler", Effect::Govern, GateSubject::None)
            .meta()
            .clone();
        let mut bench = bench_with(Box::new(Governs(meta)));
        let read = bench.invoke(&op_call("read"), &key(6), at()).unwrap();
        assert!(matches!(read, BenchOutcome::Ran { .. }), "{read:?}");
        let rewrite = bench
            .invoke(&op_call("propose"), &key(7), at())
            .unwrap_err();
        assert_eq!(rewrite.code(), &AxCode::GateDenied);
    }

    fn bench_with(tool: Box<dyn Tool>) -> ToolBench {
        let domain = WriteDomain::new(vec![Address::parse("work").unwrap()]).unwrap();
        let mut bench = ToolBench::new(domain);
        bench.register(tool).unwrap();
        bench
    }

    fn call(tool: &str, args: kernel::Payload) -> ToolCall {
        ToolCall {
            id: "c-1".to_owned(),
            name: kernel::ToolName::parse(tool).unwrap(),
            args,
        }
    }

    fn key(n: u64) -> IdemKey {
        IdemKey::derive(&RunId::from_bytes([1u8; 16]), Seq::new(n), b"door")
    }

    fn at() -> kernel::TimeMs {
        kernel::TimeMs::new(1_700_000_000_000)
    }

    #[test]
    fn an_egress_call_that_names_no_host_is_still_scanned() {
        let mut bench = bench_with(stub("webbish", Effect::Egress, GateSubject::None));
        let secret = format!("sk-ant-{}", "a1B2c3D4e5".repeat(9));
        let mut args = serde_json::Map::new();
        args.insert("text".to_owned(), Value::String(secret));
        let answered = bench
            .invoke(
                &call("webbish", kernel::Payload::new(args).unwrap()),
                &key(1),
                at(),
            )
            .unwrap();
        assert!(matches!(answered, BenchOutcome::Refused { .. }));
    }

    #[test]
    fn an_egress_call_that_names_no_host_still_runs_when_the_bytes_are_clean() {
        let mut bench = bench_with(stub("webbish", Effect::Egress, GateSubject::None));
        let answered = bench
            .invoke(&call("webbish", kernel::Payload::empty()), &key(2), at())
            .unwrap();
        assert!(matches!(answered, BenchOutcome::Ran { .. }));
    }

    #[test]
    fn an_egress_call_that_answers_with_an_area_is_refused_as_a_wiring_defect() {
        let area = Address::parse("work/room").unwrap();
        let mut bench = bench_with(stub("webbish", Effect::Egress, GateSubject::Area(area)));
        let refused = bench.invoke(&call("webbish", kernel::Payload::empty()), &key(3), at());
        assert!(refused.is_err());
    }

    #[test]
    fn a_govern_call_that_names_no_scope_is_refused_as_a_tool_that_named_none() {
        let mut bench = bench_with(stub("ruler", Effect::Govern, GateSubject::None));
        let refused = bench
            .invoke(&call("ruler", kernel::Payload::empty()), &key(4), at())
            .unwrap_err();
        assert!(refused.subject().contains("no subject"));
    }

    #[test]
    fn a_govern_refusal_tells_the_model_its_recovery_without_a_run_of_spaces() {
        let scope = GateSubject::Scope("work".to_owned());
        let mut bench = bench_with(stub("ruler", Effect::Govern, scope));
        let refused = bench
            .invoke(&call("ruler", kernel::Payload::empty()), &key(5), at())
            .unwrap_err();
        assert_eq!(
            refused.recovery(),
            "edit the scope's `CONFIG.toml`, or the building's `RULES.toml`, and dispatch again"
        );
    }
}