Skip to main content

safe_chains/engine/
archetype.rs

1//! Static capability archetypes (`docs/design/behavioral-taxonomy-archetypes.md`). The recurring
2//! facet profiles the Phase-1 subcommand surface classifies against. Each archetype is a fixed
3//! [`Capability`] declared in `archetypes.toml`; a subcommand references one by name (`profile =
4//! "remote-mutate"`) and the resolver emits that capability directly (a static profile — the
5//! sub's facets don't depend on its arguments, unlike the operand-role commands of Phase 0).
6//!
7//! This is the archetype as a *reusable audited bundle*, never a unit of analysis: `profile = …`
8//! expands to the explicit capability here, the researcher still verifies the sub genuinely is
9//! that archetype and cites it (the per-item provenance schema). Facet fields take an EXACT term,
10//! not a bound — these are points in facet-space, not the level predicates of `authoring`.
11
12use std::collections::BTreeMap;
13use std::sync::LazyLock;
14
15use serde::Deserialize;
16
17use super::facet::{Capability, FacetTerm, Operation};
18
19/// The capability an archetype expands to, or `None` if the name is unknown (fail-closed: an
20/// unknown `profile = …` must not silently resolve to nothing).
21pub fn archetype(name: &str) -> Option<&'static Capability> {
22    ARCHETYPES.get(name)
23}
24
25/// Every archetype name, for the `profile = …` closed-set check and the docs.
26pub 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
34fn build_archetypes(src: &str) -> Result<BTreeMap<String, Capability>, String> {
35    let set: TomlArchetypeSet = toml::from_str(src).map_err(|e| e.to_string())?;
36    set.archetype
37        .into_iter()
38        .map(|(name, tc)| build_capability(&name, tc).map(|c| (name, c)))
39        .collect()
40}
41
42fn build_capability(name: &str, tc: TomlCapability) -> Result<Capability, String> {
43    let operation = Operation::from_term(&tc.operation)
44        .ok_or_else(|| format!("archetype `{name}`: unknown operation `{}`", tc.operation))?;
45    let mut c = Capability::new(operation);
46
47    if let Some(l) = &tc.locus {
48        set_term(name, "locus.local", l.local.as_deref(), &mut c.locus.local)?;
49        set_term(name, "locus.remote", l.remote.as_deref(), &mut c.locus.remote)?;
50        set_term(name, "locus.binding", l.binding.as_deref(), &mut c.locus.binding)?;
51        set_term(name, "locus.provenance", l.provenance.as_deref(), &mut c.locus.provenance)?;
52    }
53    set_term(name, "scale", tc.scale.as_deref(), &mut c.scale)?;
54    set_term(name, "retrieval", tc.retrieval.as_deref(), &mut c.retrieval)?;
55    set_term(name, "authority", tc.authority.as_deref(), &mut c.authority)?;
56    set_term(name, "reversibility", tc.reversibility.as_deref(), &mut c.reversibility)?;
57    if let Some(p) = &tc.persistence {
58        set_term(name, "persistence.level", p.level.as_deref(), &mut c.persistence.level)?;
59    }
60    if let Some(d) = &tc.disclosure {
61        set_term(name, "disclosure.audience", d.audience.as_deref(), &mut c.disclosure.audience)?;
62    }
63    if let Some(s) = &tc.secret {
64        set_term(name, "secret.level", s.level.as_deref(), &mut c.secret.level)?;
65    }
66    if let Some(net) = &tc.network {
67        set_term(name, "network.direction", net.direction.as_deref(), &mut c.network.direction)?;
68        set_term(name, "network.destination", net.destination.as_deref(), &mut c.network.destination)?;
69        set_term(name, "network.payload", net.payload.as_deref(), &mut c.network.payload)?;
70    }
71    set_term(name, "execution", tc.execution.as_deref(), &mut c.execution.trust)?;
72    set_term(name, "cost", tc.cost.as_deref(), &mut c.cost)?;
73
74    if tc.because.trim().is_empty() {
75        return Err(format!("archetype `{name}`: `because` is required"));
76    }
77    c.because = tc.because;
78    Ok(c)
79}
80
81/// Parse an optional term into `slot`, leaving the zero-term default when absent. An unrecognized
82/// term is a compile error naming the archetype and facet (fail-closed, never a silent skip).
83fn set_term<T: FacetTerm>(name: &str, field: &str, s: Option<&str>, slot: &mut T) -> Result<(), String> {
84    if let Some(v) = s {
85        *slot = T::from_term(v).ok_or_else(|| format!("archetype `{name}`: unknown {field} term `{v}`"))?;
86    }
87    Ok(())
88}
89
90#[derive(Deserialize)]
91struct TomlArchetypeSet {
92    #[serde(default)]
93    archetype: BTreeMap<String, TomlCapability>,
94}
95
96#[derive(Deserialize)]
97#[serde(deny_unknown_fields)]
98struct TomlCapability {
99    operation: String,
100    because: String,
101    #[serde(default)]
102    locus: Option<TomlLocus>,
103    #[serde(default)]
104    scale: Option<String>,
105    #[serde(default)]
106    retrieval: Option<String>,
107    #[serde(default)]
108    authority: Option<String>,
109    #[serde(default)]
110    reversibility: Option<String>,
111    #[serde(default)]
112    persistence: Option<TomlPersistence>,
113    #[serde(default)]
114    disclosure: Option<TomlDisclosure>,
115    #[serde(default)]
116    secret: Option<TomlSecret>,
117    #[serde(default)]
118    network: Option<TomlNetwork>,
119    #[serde(default)]
120    execution: Option<String>,
121    #[serde(default)]
122    cost: Option<String>,
123}
124
125#[derive(Deserialize)]
126#[serde(deny_unknown_fields)]
127struct TomlLocus {
128    local: Option<String>,
129    remote: Option<String>,
130    binding: Option<String>,
131    provenance: Option<String>,
132}
133
134#[derive(Deserialize)]
135#[serde(deny_unknown_fields)]
136struct TomlPersistence {
137    level: Option<String>,
138}
139
140#[derive(Deserialize)]
141#[serde(deny_unknown_fields)]
142struct TomlDisclosure {
143    audience: Option<String>,
144}
145
146#[derive(Deserialize)]
147#[serde(deny_unknown_fields)]
148struct TomlSecret {
149    level: Option<String>,
150}
151
152#[derive(Deserialize)]
153#[serde(deny_unknown_fields)]
154struct TomlNetwork {
155    direction: Option<String>,
156    destination: Option<String>,
157    payload: Option<String>,
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::engine::authoring::default_levels;
164    use crate::engine::bridge::project;
165    use crate::engine::facet::Profile;
166    use crate::engine::level::Level;
167    use crate::verdict::Verdict;
168
169    fn level(name: &str) -> &'static Level {
170        default_levels().iter().find(|l| l.name == name).expect("level exists")
171    }
172
173    /// The `adjacent` (sibling-workspace) locus lands where the design says, INDEPENDENT of the legacy
174    /// 3-band CLI projection (which collapses editor/developer to SafeWrite). A sibling READ auto-
175    /// approves from reader up; a sibling create/mutate (a "quick patch") is admitted at developer but
176    /// NOT editor (editor's writes stay `<= worktree`); a sibling DESTROY is withheld even at developer
177    /// (conservative — `rm -rf ../otherrepo` is not a patch).
178    #[test]
179    fn sibling_adjacent_locus_reads_at_reader_writes_at_developer_not_editor() {
180        use crate::engine::facet::{Capability, LocalLocus, Operation, PersistenceLevel, Reversibility};
181        let at_adjacent = |op: Operation| {
182            let mut c = Capability::new(op);
183            c.locus.local = LocalLocus::Adjacent;
184            c
185        };
186        let read = Profile::of(vec![at_adjacent(Operation::Observe)]);
187        let patch = Profile::of(vec![{
188            let mut c = at_adjacent(Operation::Mutate);
189            c.reversibility = Reversibility::Recoverable;
190            c.persistence.level = PersistenceLevel::Data;
191            c
192        }]);
193        let destroy = Profile::of(vec![at_adjacent(Operation::Destroy)]);
194
195        assert!(level("reader").admits(&read), "a sibling READ auto-approves from reader");
196        assert!(level("developer").admits(&read), "…and higher");
197        assert!(!level("editor").admits(&patch), "editor does NOT write a sibling (writes stay worktree)");
198        assert!(level("developer").admits(&patch), "developer patches a sibling (create/mutate)");
199        assert!(!level("editor").admits(&destroy), "editor does NOT destroy a sibling");
200        assert!(!level("developer").admits(&destroy), "developer does NOT destroy a sibling (conservative — its destroy clause stays `<= worktree`)");
201    }
202
203    #[test]
204    fn archetypes_toml_compiles_and_every_capability_is_justified() {
205        // LazyLock forces the parse; a bad term / missing `because` would have panicked.
206        let mut count = 0;
207        for n in names() {
208            let c = archetype(n).expect("listed archetype resolves");
209            assert!(!c.because.is_empty(), "archetype `{n}` has no because");
210            count += 1;
211        }
212        assert!(count >= 10, "expected the full catalog, got {count}");
213        assert!(archetype("does-not-exist").is_none(), "unknown profile fails closed");
214    }
215
216    /// The catalog's "Lands at" column, verified against the real algebra by loading the ACTUAL
217    /// archetype definitions (not hand-built copies): each is admitted by its claimed level and
218    /// refused by the level just below it. Ties archetypes.toml ↔ the catalog doc ↔ the levels.
219    #[test]
220    fn archetypes_land_where_the_catalog_says() {
221        // (archetype, admitted_by, refused_by)
222        let cases: &[(&str, &str, &str)] = &[
223            // A pure remote fetch is a READ — reader admits it; only paranoid (no network) refuses.
224            ("remote-read", "reader", "paranoid"),
225            // A BULK remote export (db dump to stdout) is still a read — reader admits it. `scale`
226            // records the volume but does not gate a read; the -f output file is a SEPARATE cap.
227            ("data-export", "reader", "paranoid"),
228            ("remote-mutate", "network-admin", "developer"),
229            ("remote-create", "network-admin", "developer"),
230            ("remote-destroy-recoverable", "network-admin", "developer"),
231            ("remote-destroy-irreversible", "yolo", "network-admin"),
232            ("remote-authorize", "network-admin", "developer"),
233            ("remote-control", "network-admin", "developer"),
234            ("vcs-sync", "network-admin", "developer"),
235            ("blockchain-txn", "yolo", "network-admin"),
236            ("local-privileged", "local-admin", "developer"),
237            // Transient service control (systemctl restart) — the mildest root-machine op, still local-admin.
238            ("privileged-control", "local-admin", "developer"),
239            // A pinned, scripts-off install runs no foreign code → developer (via the install clause).
240            ("local-install-pinned", "developer", "editor"),
241            // The scripts-on / unpinned install RUNS foreign code (network-sourced) → yolo only.
242            ("supply-chain-build", "yolo", "developer"),
243            // Arbitrary remote code execution (kubectl exec, ssh cmd) — execute op, no level below yolo.
244            ("remote-exec", "yolo", "network-admin"),
245            // Credential material read/mint → yolo (secret > uses-ambient everywhere below yolo).
246            ("credential-read", "yolo", "network-admin"),
247            ("credential-mint", "yolo", "network-admin"),
248            // Decrypt-to-screen (sops -d, age -d, ansible-vault view): a secret read that flows to the
249            // model — same `secret = reads` tier as a credential-store read → yolo, refused below.
250            ("decrypt-read", "yolo", "network-admin"),
251            // Arbitrary stored-object retrieval (s3 get-object): classified by `retrieval =
252            // bulk-content` (§5 #1), it lands at NETWORK-ADMIN — the proportionate bulk-egress tier —
253            // refused by developer. NOT yolo (it is not a credential read) and NOT reader (opaque bulk
254            // content is above the everyday read band).
255            ("bulk-object-read", "network-admin", "developer"),
256        ];
257        for (name, admitted_by, refused_by) in cases {
258            let p = Profile::of(vec![archetype(name).expect("archetype exists").clone()]);
259            assert!(level(admitted_by).admits(&p), "{name} should be admitted by {admitted_by}");
260            assert!(!level(refused_by).admits(&p), "{name} should be refused by {refused_by}");
261        }
262    }
263
264    /// The whole point of Phase 1 for the WRITE side: every remote archetype that CHANGES remote
265    /// state (mutate/create/destroy/authorize/control), plus vcs-sync and blockchain-txn, is above
266    /// the auto-approve band — denied by CLASSIFICATION, not hand-marking. `remote-read` is the
267    /// deliberate exception: a pure fetch is a reader-level read and auto-approves (SafeRead).
268    #[test]
269    fn every_remote_write_archetype_is_not_auto_approved() {
270        let write_remotes = names()
271            .filter(|n| (n.starts_with("remote-") && *n != "remote-read") || *n == "vcs-sync" || *n == "blockchain-txn");
272        for name in write_remotes {
273            let p = Profile::of(vec![archetype(name).expect("archetype").clone()]);
274            assert_eq!(project(&p), Verdict::Denied, "{name} must not auto-approve in the 3-value projection");
275        }
276        // and the read DOES auto-approve — the read/write asymmetry, verified
277        assert_eq!(
278            project(&Profile::of(vec![archetype("remote-read").unwrap().clone()])),
279            Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
280            "a pure remote fetch is reader-level",
281        );
282    }
283
284    /// The exposure reframe (behavioral-taxonomy-exposure.md §3, §7): `disclosure.audience = public`
285    /// is a RECORD, not a gate. Publishing content you authored to a public destination (git push to
286    /// a public repo, `npm publish`) is a network-admin operation — NOT held back to yolo by its
287    /// publicness. What still gates to yolo is CONTENT: transmitting a secret off-box. Proves the
288    /// gate moved from "how public the destination is" to "is a secret leaving". Red on the old
289    /// `disclosure = { audience = "<= trusted-remote" }` ceiling (public publish refused everywhere
290    /// below yolo); green on `<= public`.
291    #[test]
292    fn public_disclosure_is_recorded_not_gated_secret_transmission_is() {
293        use crate::engine::facet::{
294            DisclosureAudience, NetDestination, NetDirection, NetPayload, Network, RemoteReach,
295            Reversibility, SecretLevel,
296        };
297
298        let publish_to_public = || {
299            let mut c = Capability::new(Operation::Communicate);
300            c.locus.remote = RemoteReach::Arbitrary;
301            c.reversibility = Reversibility::Effortful;
302            c.disclosure.audience = DisclosureAudience::Public;
303            c.network = Network {
304                direction: NetDirection::Outbound,
305                destination: NetDestination::Arbitrary,
306                payload: NetPayload::SendsHostData,
307            };
308            c
309        };
310
311        // Non-secret public publish → a network-admin op, still above the local developer band.
312        let mut publish = publish_to_public();
313        publish.because = "publish authored content to a public destination".into();
314        let publish = Profile::of(vec![publish]);
315        assert!(level("network-admin").admits(&publish), "public non-secret publish is network-admin");
316        assert!(!level("developer").admits(&publish), "outbound remote egress is above developer");
317
318        // Same shape, but it TRANSMITS A SECRET — now the CONTENT gates it up to yolo.
319        let mut exfil = publish_to_public();
320        exfil.secret.level = SecretLevel::Transmits;
321        exfil.because = "transmit a secret off-box".into();
322        let exfil = Profile::of(vec![exfil]);
323        assert!(!level("network-admin").admits(&exfil), "secret transmission is the gate, above network-admin");
324        assert!(level("yolo").admits(&exfil), "yolo admits secret exfil (non-destroy clause)");
325    }
326
327    /// The machine locus SUB-RUNG split (the `restart nginx` vs `/etc/passwd` distinction). ORDINARY
328    /// machine state — a service, an app config — is `machine` → local-admin. The identity/auth/boot/
329    /// loader TRUST substrate is `system-integrity` → ABOVE local-admin, yolo-only. Same operation +
330    /// authority; only the locus rung differs, and that difference is the whole gate: "run the machine
331    /// as admin" vs "own the machine's trust root".
332    #[test]
333    fn system_integrity_is_above_local_admin_ordinary_machine_is_not() {
334        use crate::engine::facet::{Authority, LocalLocus};
335        let (local, yolo) = (level("local-admin"), level("yolo"));
336
337        let root_write_at = |loc| {
338            let mut c = Capability::new(Operation::Mutate);
339            c.locus.local = loc;
340            c.authority = Authority::Root;
341            c.because = "root machine write".into();
342            Profile::of(vec![c])
343        };
344
345        // ordinary machine config (edit /etc/nginx.conf as root) — local-admin admits.
346        assert!(local.admits(&root_write_at(LocalLocus::Machine)), "ordinary machine write is local-admin");
347
348        // the trust substrate (rewrite /etc/passwd as root) — local-admin REFUSES; only yolo.
349        let integrity = root_write_at(LocalLocus::SystemIntegrity);
350        assert!(!local.admits(&integrity), "the system-integrity substrate is above local-admin");
351        assert!(yolo.admits(&integrity), "yolo owns the machine's trust root");
352    }
353
354    /// The developer supply-chain / install clause. A PINNED, SCRIPTS-OFF install (`npm ci
355    /// --ignore-scripts`) fetches packages and writes node_modules but runs NO foreign code —
356    /// `execution = self`, `persistence = installing` → a dev-loop staple, admitted at developer.
357    /// The scripts-ON or UNPINNED install is `execution = network-sourced` (the supply-chain-build
358    /// archetype) → no home below yolo. The resolver picks which shape a command emits; this pins the
359    /// LEVEL boundary. Modeling the safe install as `execution = self` (not a guardrail-gated
360    /// `network-sourced`) is what keeps the clause all-`<=` and facet-monotone.
361    #[test]
362    fn pinned_scripts_off_install_is_developer_the_supply_chain_surface_is_yolo() {
363        use crate::engine::facet::{
364            ExecutionTrust, LocalLocus, NetDirection, NetPayload, PersistenceLevel, Reversibility,
365        };
366        let (dev, yolo) = (level("developer"), level("yolo"));
367
368        // `npm ci --ignore-scripts`: install files, execute nothing foreign.
369        let safe_install = {
370            let mut c = Capability::new(Operation::Create);
371            c.locus.local = LocalLocus::Worktree;
372            c.persistence.level = PersistenceLevel::Installing;
373            c.reversibility = Reversibility::Effortful;
374            c.network.direction = NetDirection::Outbound;
375            c.network.payload = NetPayload::Fetches;
376            c.execution.trust = ExecutionTrust::SelfCode;
377            c.because = "pinned, scripts-off install".into();
378            Profile::of(vec![c])
379        };
380        assert!(dev.admits(&safe_install), "a pinned, scripts-off install is developer");
381        assert!(yolo.admits(&safe_install), "and of course yolo");
382
383        // scripts-ON / unpinned: the supply-chain surface (network-sourced execution).
384        let supply_chain = Profile::of(vec![archetype("supply-chain-build").unwrap().clone()]);
385        assert!(!dev.admits(&supply_chain), "network-sourced install (scripts on / unpinned) is above developer");
386        assert!(yolo.admits(&supply_chain), "the supply-chain surface lands at yolo");
387    }
388
389    /// Destination-trust (behavioral-taxonomy-exposure.md §4): the new `locus.provenance` facet.
390    /// A send to a target designated `literal` (a URL typed inline) is a network-admin op — the
391    /// human reviewing at that level SEES the URL; a send to an `opaque` target (from a variable,
392    /// unreviewable) is held to yolo. Proves network-admin's `provenance <= literal` ceiling. Red
393    /// if the ceiling is absent (opaque would leak into network-admin) or set to `established`
394    /// (literal URLs would be wrongly refused); green at `<= literal`.
395    #[test]
396    fn a_literal_send_target_is_network_admin_an_opaque_one_is_yolo() {
397        use crate::engine::facet::{NetDirection, NetPayload, Provenance, RemoteReach};
398
399        let send_to = |prov| {
400            let mut c = Capability::new(Operation::Communicate);
401            c.locus.remote = RemoteReach::Fixed;
402            c.locus.provenance = prov;
403            c.network.direction = NetDirection::Outbound;
404            c.network.payload = NetPayload::SendsHostData;
405            c.because = "send host data to a designated target".into();
406            c
407        };
408
409        let literal = Profile::of(vec![send_to(Provenance::Literal)]);
410        assert!(level("network-admin").admits(&literal), "a visible literal URL is a network-admin send");
411        assert!(!level("developer").admits(&literal), "sends-host-data is above the local developer band");
412
413        let opaque = Profile::of(vec![send_to(Provenance::Opaque)]);
414        assert!(!level("network-admin").admits(&opaque), "an opaque (variable) destination is held above network-admin");
415        assert!(level("yolo").admits(&opaque), "yolo leaves provenance unconstrained");
416    }
417}