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