1use std::collections::BTreeMap;
13use std::sync::LazyLock;
14
15use serde::Deserialize;
16
17use super::facet::{Capability, FacetTerm, Operation};
18
19pub fn archetype(name: &str) -> Option<&'static Capability> {
22 ARCHETYPES.get(name)
23}
24
25pub fn names() -> impl Iterator<Item = &'static str> {
27 ARCHETYPES.keys().map(String::as_str)
28}
29
30static ARCHETYPES: LazyLock<BTreeMap<String, Capability>> = LazyLock::new(|| {
31 build_archetypes(include_str!("../../archetypes.toml")).expect("embedded archetypes.toml must compile")
32});
33
34#[derive(Debug, Clone, Deserialize)]
36struct TomlDistinction {
37 #[allow(dead_code)] archetype: String,
39 #[allow(dead_code)] by: String,
42}
43
44#[cfg(test)]
47fn declared_distinctions() -> Vec<(String, Vec<(String, String)>, Option<String>)> {
48 let set: TomlArchetypeSet =
49 toml::from_str(include_str!("../../archetypes.toml")).expect("archetypes.toml parses");
50 set.archetype
51 .into_iter()
52 .map(|(name, tc)| {
53 let d = tc
54 .distinguished_from
55 .into_iter()
56 .map(|x| (x.archetype, x.by))
57 .collect();
58 (name, d, tc.same_point_as)
59 })
60 .collect()
61}
62
63fn build_archetypes(src: &str) -> Result<BTreeMap<String, Capability>, String> {
64 let set: TomlArchetypeSet = toml::from_str(src).map_err(|e| e.to_string())?;
65 set.archetype
66 .into_iter()
67 .map(|(name, tc)| build_capability(&name, tc).map(|c| (name, c)))
68 .collect()
69}
70
71fn build_capability(name: &str, tc: TomlCapability) -> Result<Capability, String> {
72 let operation = Operation::from_term(&tc.operation)
73 .ok_or_else(|| format!("archetype `{name}`: unknown operation `{}`", tc.operation))?;
74 let mut c = Capability::new(operation);
75
76 if let Some(l) = &tc.locus {
77 set_term(name, "locus.local", l.local.as_deref(), &mut c.locus.local)?;
78 set_term(name, "locus.remote", l.remote.as_deref(), &mut c.locus.remote)?;
79 set_term(name, "locus.binding", l.binding.as_deref(), &mut c.locus.binding)?;
80 set_term(name, "locus.provenance", l.provenance.as_deref(), &mut c.locus.provenance)?;
81 }
82 set_term(name, "scale", tc.scale.as_deref(), &mut c.scale)?;
83 set_term(name, "retrieval", tc.retrieval.as_deref(), &mut c.retrieval)?;
84 set_term(name, "authority", tc.authority.as_deref(), &mut c.authority)?;
85 set_term(name, "reversibility", tc.reversibility.as_deref(), &mut c.reversibility)?;
86 if let Some(p) = &tc.persistence {
87 set_term(name, "persistence.level", p.level.as_deref(), &mut c.persistence.level)?;
88 }
89 if let Some(d) = &tc.disclosure {
90 set_term(name, "disclosure.audience", d.audience.as_deref(), &mut c.disclosure.audience)?;
91 }
92 if let Some(s) = &tc.secret {
93 set_term(name, "secret.level", s.level.as_deref(), &mut c.secret.level)?;
94 }
95 if let Some(net) = &tc.network {
96 set_term(name, "network.direction", net.direction.as_deref(), &mut c.network.direction)?;
97 set_term(name, "network.destination", net.destination.as_deref(), &mut c.network.destination)?;
98 set_term(name, "network.payload", net.payload.as_deref(), &mut c.network.payload)?;
99 }
100 set_term(name, "execution", tc.execution.as_deref(), &mut c.execution.trust)?;
101 set_term(name, "cost", tc.cost.as_deref(), &mut c.cost)?;
102
103 if tc.because.trim().is_empty() {
104 return Err(format!("archetype `{name}`: `because` is required"));
105 }
106 c.because = tc.because;
107 Ok(c)
108}
109
110fn set_term<T: FacetTerm>(name: &str, field: &str, s: Option<&str>, slot: &mut T) -> Result<(), String> {
113 if let Some(v) = s {
114 *slot = T::from_term(v).ok_or_else(|| format!("archetype `{name}`: unknown {field} term `{v}`"))?;
115 }
116 Ok(())
117}
118
119#[derive(Deserialize)]
120struct TomlArchetypeSet {
121 #[serde(default)]
122 archetype: BTreeMap<String, TomlCapability>,
123}
124
125#[derive(Deserialize)]
126#[serde(deny_unknown_fields)]
127struct TomlCapability {
128 operation: String,
129 because: String,
130 #[serde(default)]
133 #[allow(dead_code)] distinguished_from: Vec<TomlDistinction>,
135 #[serde(default)]
138 #[allow(dead_code)] same_point_as: Option<String>,
140 #[serde(default)]
141 locus: Option<TomlLocus>,
142 #[serde(default)]
143 scale: Option<String>,
144 #[serde(default)]
145 retrieval: Option<String>,
146 #[serde(default)]
147 authority: Option<String>,
148 #[serde(default)]
149 reversibility: Option<String>,
150 #[serde(default)]
151 persistence: Option<TomlPersistence>,
152 #[serde(default)]
153 disclosure: Option<TomlDisclosure>,
154 #[serde(default)]
155 secret: Option<TomlSecret>,
156 #[serde(default)]
157 network: Option<TomlNetwork>,
158 #[serde(default)]
159 execution: Option<String>,
160 #[serde(default)]
161 cost: Option<String>,
162}
163
164#[derive(Deserialize)]
165#[serde(deny_unknown_fields)]
166struct TomlLocus {
167 local: Option<String>,
168 remote: Option<String>,
169 binding: Option<String>,
170 provenance: Option<String>,
171}
172
173#[derive(Deserialize)]
174#[serde(deny_unknown_fields)]
175struct TomlPersistence {
176 level: Option<String>,
177}
178
179#[derive(Deserialize)]
180#[serde(deny_unknown_fields)]
181struct TomlDisclosure {
182 audience: Option<String>,
183}
184
185#[derive(Deserialize)]
186#[serde(deny_unknown_fields)]
187struct TomlSecret {
188 level: Option<String>,
189}
190
191#[derive(Deserialize)]
192#[serde(deny_unknown_fields)]
193struct TomlNetwork {
194 direction: Option<String>,
195 destination: Option<String>,
196 payload: Option<String>,
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::engine::authoring::default_levels;
203 use crate::engine::bridge::project;
204 use crate::engine::facet::Profile;
205 use crate::engine::level::Level;
206 use crate::verdict::Verdict;
207
208 fn level(name: &str) -> &'static Level {
209 default_levels().iter().find(|l| l.name == name).expect("level exists")
210 }
211
212 #[test]
218 fn sibling_adjacent_locus_reads_at_reader_writes_at_developer_not_editor() {
219 use crate::engine::facet::{Capability, LocalLocus, Operation, PersistenceLevel, Reversibility};
220 let at_adjacent = |op: Operation| {
221 let mut c = Capability::new(op);
222 c.locus.local = LocalLocus::Adjacent;
223 c
224 };
225 let read = Profile::of(vec![at_adjacent(Operation::Observe)]);
226 let patch = Profile::of(vec![{
227 let mut c = at_adjacent(Operation::Mutate);
228 c.reversibility = Reversibility::Recoverable;
229 c.persistence.level = PersistenceLevel::Data;
230 c
231 }]);
232 let destroy = Profile::of(vec![at_adjacent(Operation::Destroy)]);
233
234 assert!(level("reader").admits(&read), "a sibling READ auto-approves from reader");
235 assert!(level("developer").admits(&read), "…and higher");
236 assert!(!level("editor").admits(&patch), "editor does NOT write a sibling (writes stay worktree)");
237 assert!(level("developer").admits(&patch), "developer patches a sibling (create/mutate)");
238 assert!(!level("editor").admits(&destroy), "editor does NOT destroy a sibling");
239 assert!(!level("developer").admits(&destroy), "developer does NOT destroy a sibling (conservative — its destroy clause stays `<= worktree`)");
240 }
241
242 #[test]
243 fn archetypes_toml_compiles_and_every_capability_is_justified() {
244 let mut count = 0;
246 for n in names() {
247 let c = archetype(n).expect("listed archetype resolves");
248 assert!(!c.because.is_empty(), "archetype `{n}` has no because");
249 count += 1;
250 }
251 assert!(count >= 10, "expected the full catalog, got {count}");
252 assert!(archetype("does-not-exist").is_none(), "unknown profile fails closed");
253 }
254
255 #[test]
259 fn archetypes_land_where_the_catalog_says() {
260 let cases: &[(&str, &str, &str)] = &[
262 ("remote-read", "reader", "paranoid"),
264 ("data-export", "reader", "paranoid"),
267 ("remote-mutate", "network-admin", "developer"),
268 ("remote-create", "network-admin", "developer"),
269 ("remote-destroy-recoverable", "network-admin", "developer"),
270 ("remote-destroy-irreversible", "yolo", "network-admin"),
271 ("remote-authorize", "network-admin", "developer"),
272 ("remote-control", "network-admin", "developer"),
273 ("vcs-sync", "network-admin", "developer"),
274 ("blockchain-txn", "yolo", "network-admin"),
275 ("local-privileged", "local-admin", "developer"),
276 ("privileged-control", "local-admin", "developer"),
278 ("local-install-pinned", "developer", "editor"),
280 ("supply-chain-build", "yolo", "developer"),
282 ("remote-exec", "yolo", "network-admin"),
284 ("credential-read", "yolo", "network-admin"),
286 ("credential-mint", "yolo", "network-admin"),
287 ("decrypt-read", "yolo", "network-admin"),
290 ("bulk-object-read", "network-admin", "developer"),
295 ("local-mutate-trivial", "editor", "reader"),
300 ("local-mutate-recoverable", "editor", "reader"),
301 ("local-destroy-recoverable", "developer", "editor"),
302 ("local-destroy-irreversible", "yolo", "developer"),
303 ];
304 for (name, admitted_by, refused_by) in cases {
305 let p = Profile::of(vec![archetype(name).expect("archetype exists").clone()]);
306 assert!(level(admitted_by).admits(&p), "{name} should be admitted by {admitted_by}");
307 assert!(!level(refused_by).admits(&p), "{name} should be refused by {refused_by}");
308 }
309
310 let uncovered: Vec<&str> =
315 names().filter(|n| !cases.iter().any(|(c, _, _)| c == n)).collect();
316 assert!(
317 uncovered.is_empty(),
318 "archetype(s) with no catalog row — add (name, admitted_by, refused_by) above: {uncovered:?}",
319 );
320 }
321
322 #[test]
327 fn every_remote_write_archetype_is_not_auto_approved() {
328 let write_remotes = names()
329 .filter(|n| (n.starts_with("remote-") && *n != "remote-read") || *n == "vcs-sync" || *n == "blockchain-txn");
330 for name in write_remotes {
331 let p = Profile::of(vec![archetype(name).expect("archetype").clone()]);
332 assert_eq!(project(&p), Verdict::Denied, "{name} must not auto-approve in the 3-value projection");
333 }
334 assert_eq!(
336 project(&Profile::of(vec![archetype("remote-read").unwrap().clone()])),
337 Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
338 "a pure remote fetch is reader-level",
339 );
340 }
341
342 #[test]
350 fn public_disclosure_is_recorded_not_gated_secret_transmission_is() {
351 use crate::engine::facet::{
352 DisclosureAudience, NetDestination, NetDirection, NetPayload, Network, RemoteReach,
353 Reversibility, SecretLevel,
354 };
355
356 let publish_to_public = || {
357 let mut c = Capability::new(Operation::Communicate);
358 c.locus.remote = RemoteReach::Arbitrary;
359 c.reversibility = Reversibility::Effortful;
360 c.disclosure.audience = DisclosureAudience::Public;
361 c.network = Network {
362 direction: NetDirection::Outbound,
363 destination: NetDestination::Arbitrary,
364 payload: NetPayload::SendsHostData,
365 };
366 c
367 };
368
369 let mut publish = publish_to_public();
371 publish.because = "publish authored content to a public destination".into();
372 let publish = Profile::of(vec![publish]);
373 assert!(level("network-admin").admits(&publish), "public non-secret publish is network-admin");
374 assert!(!level("developer").admits(&publish), "outbound remote egress is above developer");
375
376 let mut exfil = publish_to_public();
378 exfil.secret.level = SecretLevel::Transmits;
379 exfil.because = "transmit a secret off-box".into();
380 let exfil = Profile::of(vec![exfil]);
381 assert!(!level("network-admin").admits(&exfil), "secret transmission is the gate, above network-admin");
382 assert!(level("yolo").admits(&exfil), "yolo admits secret exfil (non-destroy clause)");
383 }
384
385 #[test]
391 fn system_integrity_is_above_local_admin_ordinary_machine_is_not() {
392 use crate::engine::facet::{Authority, LocalLocus};
393 let (local, yolo) = (level("local-admin"), level("yolo"));
394
395 let root_write_at = |loc| {
396 let mut c = Capability::new(Operation::Mutate);
397 c.locus.local = loc;
398 c.authority = Authority::Root;
399 c.because = "root machine write".into();
400 Profile::of(vec![c])
401 };
402
403 assert!(local.admits(&root_write_at(LocalLocus::Machine)), "ordinary machine write is local-admin");
405
406 let integrity = root_write_at(LocalLocus::SystemIntegrity);
408 assert!(!local.admits(&integrity), "the system-integrity substrate is above local-admin");
409 assert!(yolo.admits(&integrity), "yolo owns the machine's trust root");
410 }
411
412 #[test]
420 fn pinned_scripts_off_install_is_developer_the_supply_chain_surface_is_yolo() {
421 use crate::engine::facet::{
422 ExecutionTrust, LocalLocus, NetDirection, NetPayload, PersistenceLevel, Reversibility,
423 };
424 let (dev, yolo) = (level("developer"), level("yolo"));
425
426 let safe_install = {
428 let mut c = Capability::new(Operation::Create);
429 c.locus.local = LocalLocus::Worktree;
430 c.persistence.level = PersistenceLevel::Installing;
431 c.reversibility = Reversibility::Effortful;
432 c.network.direction = NetDirection::Outbound;
433 c.network.payload = NetPayload::Fetches;
434 c.execution.trust = ExecutionTrust::SelfCode;
435 c.because = "pinned, scripts-off install".into();
436 Profile::of(vec![c])
437 };
438 assert!(dev.admits(&safe_install), "a pinned, scripts-off install is developer");
439 assert!(yolo.admits(&safe_install), "and of course yolo");
440
441 let supply_chain = Profile::of(vec![archetype("supply-chain-build").unwrap().clone()]);
443 assert!(!dev.admits(&supply_chain), "network-sourced install (scripts on / unpinned) is above developer");
444 assert!(yolo.admits(&supply_chain), "the supply-chain surface lands at yolo");
445 }
446
447 #[test]
454 fn a_literal_send_target_is_network_admin_an_opaque_one_is_yolo() {
455 use crate::engine::facet::{NetDirection, NetPayload, Provenance, RemoteReach};
456
457 let send_to = |prov| {
458 let mut c = Capability::new(Operation::Communicate);
459 c.locus.remote = RemoteReach::Fixed;
460 c.locus.provenance = prov;
461 c.network.direction = NetDirection::Outbound;
462 c.network.payload = NetPayload::SendsHostData;
463 c.because = "send host data to a designated target".into();
464 c
465 };
466
467 let literal = Profile::of(vec![send_to(Provenance::Literal)]);
468 assert!(level("network-admin").admits(&literal), "a visible literal URL is a network-admin send");
469 assert!(!level("developer").admits(&literal), "sends-host-data is above the local developer band");
470
471 let opaque = Profile::of(vec![send_to(Provenance::Opaque)]);
472 assert!(!level("network-admin").admits(&opaque), "an opaque (variable) destination is held above network-admin");
473 assert!(level("yolo").admits(&opaque), "yolo leaves provenance unconstrained");
474 }
475}
476
477#[cfg(test)]
478mod neighbour_tests {
479 use super::*;
480 use std::collections::BTreeMap;
481
482 fn differing_facets(a: &Capability, b: &Capability) -> Vec<&'static str> {
485 let am: BTreeMap<_, _> = a.set_facets().into_iter().collect();
486 let bm: BTreeMap<_, _> = b.set_facets().into_iter().collect();
487 let mut keys: Vec<_> = am.keys().chain(bm.keys()).copied().collect();
488 keys.sort_unstable();
489 keys.dedup();
490 keys.into_iter().filter(|k| am.get(k) != bm.get(k)).collect()
491 }
492
493 #[test]
506 fn near_neighbours_are_declared() {
507 let declared = declared_distinctions();
508 let dist_of = |n: &str| -> Vec<(String, String)> {
509 declared.iter().find(|(name, ..)| name == n).map(|(_, d, _)| d.clone()).unwrap_or_default()
510 };
511 let same_of = |n: &str| -> Option<String> {
512 declared.iter().find(|(name, ..)| name == n).and_then(|(_, _, s)| s.clone())
513 };
514
515 let names: Vec<&str> = names().collect();
516 let mut problems = Vec::new();
517 for (i, a) in names.iter().enumerate() {
518 for b in &names[i + 1..] {
519 let d = differing_facets(archetype(a).unwrap(), archetype(b).unwrap());
520 match d.len() {
521 0 => {
522 if same_of(a).as_deref() != Some(*b) || same_of(b).as_deref() != Some(*a) {
526 problems.push(format!(
527 "`{a}` and `{b}` are facet-IDENTICAL; both must declare \
528 `same_point_as` naming the other, or be given a real difference",
529 ));
530 }
531 }
532 1 => {
533 let axis = d[0];
534 for (x, y) in [(a, b), (b, a)] {
535 if !dist_of(x).iter().any(|(n, by)| n == *y && by == axis) {
536 problems.push(format!(
537 "`{x}` must declare `distinguished_from = [{{ archetype = \"{y}\", \
538 by = \"{axis}\" }}]` — they differ on that axis alone",
539 ));
540 }
541 }
542 }
543 _ => {}
544 }
545 }
546 }
547 assert!(problems.is_empty(), "confusable archetypes:\n {}", problems.join("\n "));
548 }
549
550 #[test]
553 fn declared_distinctions_are_accurate() {
554 let mut problems = Vec::new();
555 for (name, dists, same) in declared_distinctions() {
556 let Some(a) = archetype(&name) else { continue };
557 for (other, by) in dists {
558 let Some(b) = archetype(&other) else {
559 problems.push(format!("`{name}` names unknown archetype `{other}`"));
560 continue;
561 };
562 let d = differing_facets(a, b);
563 if !d.contains(&by.as_str()) {
564 problems.push(format!(
565 "`{name}` claims it differs from `{other}` by `{by}`, but they differ on {d:?}",
566 ));
567 }
568 }
569 if let Some(other) = same {
570 match archetype(&other) {
571 None => problems.push(format!("`{name}` names unknown archetype `{other}`")),
572 Some(b) => {
573 let d = differing_facets(a, b);
574 if !d.is_empty() {
575 problems.push(format!(
576 "`{name}` claims `same_point_as = \"{other}\"`, but they differ on {d:?}",
577 ));
578 }
579 }
580 }
581 }
582 }
583 assert!(problems.is_empty(), "inaccurate distinctions:\n {}", problems.join("\n "));
584 }
585}