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}