Skip to main content

relon_eval_api/
capability.rs

1//! Unified capability decision boundary across evaluator backends.
2//!
3//! Every backend asks the same question before dispatching a guarded
4//! native fn: *does the host grant the bit this fn declared?*
5//! [`CapabilityGate`] is that single decision; the backends differ
6//! only in *when* they consult it:
7//!
8//! * **Tree-walker** — at dispatch time, on every native-fn call site
9//!   (`check_native_fn_capability` delegates straight to the gate).
10//! * **Cranelift-native** — at vtable-build time (once per `run_main`):
11//!   `CapabilityVtable::register_via_gate` consults the gate so a
12//!   denied bit is materialised as a null slot, and the in-IR
13//!   `cap_lookup` + null-check then traps on a denied call.
14//! * **Bytecode VM** — at dispatch time, via the per-call-site
15//!   `consult_gate` consult before any guarded op touches the stack.
16//!
17//! A denial surfaces as [`RuntimeError::CapabilityDenied`] across all
18//! three (the compiled backends carry only the numeric `cap_bit`; the
19//! tree-walker also fills a human-readable `reason`).
20//!
21//! [`RuntimeError::CapabilityDenied`]: crate::RuntimeError::CapabilityDenied
22//!
23//! Hosts that need a custom policy (e.g. trust-level thresholding,
24//! per-call audit logging) implement [`CapabilityGate`] and wire it
25//! anywhere the default `Capabilities`-driven gate is used today.
26
27use crate::context::{Capabilities, CapabilityBit, NativeFnGate};
28
29/// Single source of capability-policy truth for evaluator backends.
30///
31/// Implementations answer "is this capability bit granted for the
32/// current evaluation context?". The default impl on
33/// [`Capabilities`] reads the per-bit boolean fields; hosts can wrap
34/// the default with auditing / trust-level layers by writing their
35/// own impl.
36///
37/// The trait is intentionally minimal: one method, immutable
38/// receiver, no async, no allocations. Backends must be able to call
39/// this on hot paths (every native-fn dispatch for the tree-walker;
40/// once per `run_main` for cranelift) without contention.
41pub trait CapabilityGate: Send + Sync {
42    /// Return `Ok(())` if the bit is granted; `Err(cap)` carrying the
43    /// denied bit otherwise.
44    fn check(&self, cap: CapabilityBit) -> Result<(), CapabilityBit>;
45
46    /// Check every bit set on `gate`, short-circuit on the first
47    /// denial. Returns `Ok(())` when the gate is fully satisfied —
48    /// the canonical "may this native fn dispatch" question.
49    ///
50    /// The default impl walks the bits in `NativeFnGate::missing_bits`
51    /// order so the failing bit matches the tree-walker's historical
52    /// "first-missing" diagnostic shape. Implementations that want a
53    /// different reporting order should override.
54    fn check_gate(&self, gate: &NativeFnGate) -> Result<(), CapabilityBit> {
55        if gate.reads_fs {
56            self.check(CapabilityBit::ReadsFs)?;
57        }
58        if gate.writes_fs {
59            self.check(CapabilityBit::WritesFs)?;
60        }
61        if gate.network {
62            self.check(CapabilityBit::Network)?;
63        }
64        if gate.reads_clock {
65            self.check(CapabilityBit::ReadsClock)?;
66        }
67        if gate.reads_env {
68            self.check(CapabilityBit::ReadsEnv)?;
69        }
70        if gate.uses_rng {
71            self.check(CapabilityBit::UsesRng)?;
72        }
73        Ok(())
74    }
75}
76
77// `CapabilityBit::as_str` / `deny_message` are inherent methods on the
78// canonical type, which now lives in the `relon-cap` leaf crate (see
79// `relon_cap::CapabilityBit`). They are reachable here through the
80// `crate::context` re-export, so no redefinition is needed.
81
82/// Default gate implementation: consult the per-bit booleans on a
83/// [`Capabilities`] snapshot.
84///
85/// `&Capabilities` is the natural carrier on the tree-walker path —
86/// the `Context` already owns one. The cranelift backend constructs
87/// its `CapabilityVtable` from this gate as well, so the two paths
88/// share the exact same policy.
89impl CapabilityGate for Capabilities {
90    fn check(&self, cap: CapabilityBit) -> Result<(), CapabilityBit> {
91        let granted = match cap {
92            CapabilityBit::ReadsFs => self.reads_fs,
93            CapabilityBit::WritesFs => self.writes_fs,
94            CapabilityBit::Network => self.network,
95            CapabilityBit::ReadsClock => self.reads_clock,
96            CapabilityBit::ReadsEnv => self.reads_env,
97            CapabilityBit::UsesRng => self.uses_rng,
98        };
99        if granted {
100            Ok(())
101        } else {
102            Err(cap)
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn default_capabilities_deny_every_bit() {
113        // The zero-trust default: every check returns the denied bit.
114        let caps = Capabilities::default();
115        for bit in [
116            CapabilityBit::ReadsFs,
117            CapabilityBit::WritesFs,
118            CapabilityBit::Network,
119            CapabilityBit::ReadsClock,
120            CapabilityBit::ReadsEnv,
121            CapabilityBit::UsesRng,
122        ] {
123            let denied = caps.check(bit).expect_err("must deny");
124            assert_eq!(denied, bit);
125        }
126    }
127
128    #[test]
129    fn all_granted_satisfies_every_bit() {
130        let caps = Capabilities::all_granted();
131        for bit in [
132            CapabilityBit::ReadsFs,
133            CapabilityBit::WritesFs,
134            CapabilityBit::Network,
135            CapabilityBit::ReadsClock,
136            CapabilityBit::ReadsEnv,
137            CapabilityBit::UsesRng,
138        ] {
139            caps.check(bit).expect("must grant");
140        }
141    }
142
143    #[test]
144    fn check_gate_short_circuits_on_first_missing_bit() {
145        // Mirrors the tree-walker's historical "first-missing"
146        // diagnostic: `reads_fs` is declared before `network` in the
147        // field order, so it surfaces first.
148        let caps = Capabilities::default();
149        // `NativeFnGate` is `#[non_exhaustive]` (defined in `relon-cap`),
150        // so build via default + field set rather than a struct literal.
151        let mut gate = NativeFnGate::default();
152        gate.reads_fs = true;
153        gate.network = true;
154        let denied = caps.check_gate(&gate).expect_err("must deny");
155        assert_eq!(denied, CapabilityBit::ReadsFs);
156    }
157
158    #[test]
159    fn check_gate_passes_when_every_required_bit_granted() {
160        let mut caps = Capabilities::default();
161        caps.reads_fs = true;
162        caps.network = true;
163        let mut gate = NativeFnGate::default();
164        gate.reads_fs = true;
165        gate.network = true;
166        caps.check_gate(&gate).expect("must allow");
167    }
168
169    #[test]
170    fn pure_gate_passes_against_zero_grant() {
171        // The pure-fn case: an all-zero gate is trivially satisfied
172        // even by the fully-sandboxed default. This is the property
173        // `register_pure_fn` relies on.
174        let caps = Capabilities::default();
175        let gate = NativeFnGate::default();
176        caps.check_gate(&gate).expect("pure gate must always pass");
177    }
178
179    #[test]
180    fn deny_message_carries_capability_name() {
181        assert!(CapabilityBit::Network.deny_message().contains("network"));
182        assert!(CapabilityBit::ReadsFs.deny_message().contains("reads_fs"));
183    }
184
185    /// A host-supplied gate that always denies, to demonstrate the
186    /// custom-policy extension point.
187    struct DenyAllGate;
188    impl CapabilityGate for DenyAllGate {
189        fn check(&self, cap: CapabilityBit) -> Result<(), CapabilityBit> {
190            Err(cap)
191        }
192    }
193
194    #[test]
195    fn host_supplied_gate_can_override_policy() {
196        let mut gate = NativeFnGate::default();
197        gate.reads_fs = true;
198        let denied = DenyAllGate.check_gate(&gate).expect_err("must deny");
199        assert_eq!(denied, CapabilityBit::ReadsFs);
200    }
201}