1use super::*;
10
11impl ToolBench {
12 #[must_use]
18 pub fn limit_egress(mut self, list: kernel::EgressAllowlist) -> ToolBench {
19 self.egress = Some(list);
20 self
21 }
22
23 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 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 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 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 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 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 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 }
153 Effect::Govern => {
154 let scope = match subject {
160 GateSubject::Scope(scope) => scope.clone(),
161 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 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 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 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 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
249fn 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
267fn 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
297fn 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 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 #[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 #[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 #[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}