Skip to main content

relon_cap/
lib.rs

1#![forbid(unsafe_code)]
2//! Canonical capability data types, deduplicated into a zero-dependency
3//! leaf crate.
4//!
5//! These pure-data types were historically defined in `relon-eval-api`
6//! (`CapabilityBit`, `NativeFnGate`, `Capabilities`) and mirrored
7//! field-for-field in `relon-analyzer` to avoid a dependency cycle (the
8//! analyzer sits *below* the evaluator API in the dep graph, so it could
9//! not reach back into it). Hosting them here lets **both** crates depend
10//! on a single definition and re-export it at their historical public
11//! paths, so every `relon_eval_api::CapabilityBit` /
12//! `relon_analyzer::cap::NativeFnGate` reference keeps resolving while the
13//! mirror is gone.
14//!
15//! The enforcement machinery (`CapabilityGate`, `GatedNativeFn`,
16//! `NativeFnCaps`) deliberately stays in `relon-eval-api`: it references
17//! eval-api types and is not pure data. Only the bit/grant/requirement
18//! data lives here.
19
20/// Canonical assignment of capability bits to stable bit positions.
21///
22/// Each variant's discriminant is the bit index the compiled backends
23/// key on: the cranelift `CapabilityVtable` slots a host fn at
24/// `cap_bit`, the LLVM / wasm host boundaries consult the same index,
25/// and the wasm `__relon_check_cap` import receives it. Hosts registering a
26/// `#native` function tag the registration with the matching bit.
27///
28/// Discriminants are stable: adding a new capability appends a new
29/// variant rather than reshuffling existing values, so previously
30/// emitted modules keep validating against the same bit positions.
31#[repr(u32)]
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CapabilityBit {
34    /// Filesystem reads. Mirrors `Capabilities::reads_fs` /
35    /// `NativeFnGate::reads_fs`.
36    ReadsFs = 0,
37    /// Filesystem writes. Mirrors `Capabilities::writes_fs` /
38    /// `NativeFnGate::writes_fs`.
39    WritesFs = 1,
40    /// Network access (sockets, HTTP, DNS). Mirrors
41    /// `Capabilities::network` / `NativeFnGate::network`.
42    Network = 2,
43    /// Wall / monotonic clock reads. Mirrors
44    /// `Capabilities::reads_clock` / `NativeFnGate::reads_clock`.
45    ReadsClock = 3,
46    /// Process environment reads. Mirrors `Capabilities::reads_env` /
47    /// `NativeFnGate::reads_env`.
48    ReadsEnv = 4,
49    /// Random-number / non-deterministic source reads. Mirrors
50    /// `Capabilities::uses_rng` / `NativeFnGate::uses_rng`.
51    UsesRng = 5,
52}
53
54impl CapabilityBit {
55    /// Stable bit index this capability claims. Used by the cranelift
56    /// vtable and LLVM / wasm host-boundary checks to key the same
57    /// capability across backends.
58    pub fn bit_index(self) -> u32 {
59        self as u32
60    }
61
62    /// Stable, audit-visible string label for this capability.
63    /// Mirrors the `NativeFnGate::missing_bits` field-name strings
64    /// so historical diagnostics keep the same wording.
65    pub fn as_str(self) -> &'static str {
66        match self {
67            CapabilityBit::ReadsFs => "reads_fs",
68            CapabilityBit::WritesFs => "writes_fs",
69            CapabilityBit::Network => "network",
70            CapabilityBit::ReadsClock => "reads_clock",
71            CapabilityBit::ReadsEnv => "reads_env",
72            CapabilityBit::UsesRng => "uses_rng",
73        }
74    }
75
76    /// Human-readable denial message for the `reason` field of
77    /// `RuntimeError::CapabilityDenied`. The dominant (and only
78    /// `Capabilities`-produced) case: the fn declared this bit but the
79    /// caller never granted it.
80    pub fn deny_message(self) -> String {
81        format!(
82            "function declared `{}` but caller did not grant it",
83            self.as_str()
84        )
85    }
86}
87
88/// Capability requirements declared *per native function* at registration
89/// time. The gate compares these against the context-wide
90/// [`Capabilities`] grant when the function is invoked under sandbox.
91///
92/// A pure function (no host capability needed) carries
93/// `NativeFnGate::default()` — every bit zero. The gate check is
94/// trivially satisfied by any `Capabilities` value, including a
95/// fully-sandboxed [`Capabilities::default`].
96///
97/// `#[non_exhaustive]`: future capability bits are added here without a
98/// breaking semver bump. External callers should construct via
99/// `NativeFnGate::default()` and set the bits they need rather than
100/// relying on positional struct literals.
101#[derive(Debug, Clone, Default)]
102#[non_exhaustive]
103pub struct NativeFnGate {
104    /// Function reads from the filesystem.
105    pub reads_fs: bool,
106    /// Function writes to or mutates the filesystem.
107    pub writes_fs: bool,
108    /// Function makes network requests.
109    pub network: bool,
110    /// Function reads wall / monotonic clocks.
111    pub reads_clock: bool,
112    /// Function reads process environment.
113    pub reads_env: bool,
114    /// Function consumes randomness from a non-deterministic source.
115    pub uses_rng: bool,
116}
117
118impl NativeFnGate {
119    /// Capability bits required by this gate that are *not* granted in
120    /// `caps`. Iteration order is the field-declaration order; runtime
121    /// uses the first entry as the failure reason, analyzer emits one
122    /// diagnostic per entry. The returned strings are the canonical
123    /// [`CapabilityBit::as_str`] labels (`"reads_fs"`, `"writes_fs"`,
124    /// `"network"`, `"reads_clock"`, `"reads_env"`, `"uses_rng"`).
125    pub fn missing_bits(&self, caps: &Capabilities) -> Vec<&'static str> {
126        let mut out = Vec::with_capacity(6);
127        if self.reads_fs && !caps.reads_fs {
128            out.push(CapabilityBit::ReadsFs.as_str());
129        }
130        if self.writes_fs && !caps.writes_fs {
131            out.push(CapabilityBit::WritesFs.as_str());
132        }
133        if self.network && !caps.network {
134            out.push(CapabilityBit::Network.as_str());
135        }
136        if self.reads_clock && !caps.reads_clock {
137            out.push(CapabilityBit::ReadsClock.as_str());
138        }
139        if self.reads_env && !caps.reads_env {
140            out.push(CapabilityBit::ReadsEnv.as_str());
141        }
142        if self.uses_rng && !caps.uses_rng {
143            out.push(CapabilityBit::UsesRng.as_str());
144        }
145        out
146    }
147
148    /// Capability bit indices this gate requires, in field-declaration
149    /// order, **regardless of any grant**. The IR lowering pass emits
150    /// one [`CapabilityBit`]-tagged `Op::CheckCap` per entry ahead of
151    /// the guarded `Op::CallNative`, so the runtime consult fires on
152    /// every required bit (the grant is checked at dispatch time, not
153    /// here). Mirrors [`Self::missing_bits`]'s ordering but drops the
154    /// grant filter — lowering doesn't know the host's runtime posture,
155    /// only the static requirement. Indices match
156    /// [`CapabilityBit::bit_index`] (ReadsFs=0 … UsesRng=5).
157    pub fn required_bit_indices(&self) -> Vec<u32> {
158        let mut out = Vec::with_capacity(6);
159        if self.reads_fs {
160            out.push(CapabilityBit::ReadsFs.bit_index());
161        }
162        if self.writes_fs {
163            out.push(CapabilityBit::WritesFs.bit_index());
164        }
165        if self.network {
166            out.push(CapabilityBit::Network.bit_index());
167        }
168        if self.reads_clock {
169            out.push(CapabilityBit::ReadsClock.bit_index());
170        }
171        if self.reads_env {
172            out.push(CapabilityBit::ReadsEnv.bit_index());
173        }
174        if self.uses_rng {
175            out.push(CapabilityBit::UsesRng.bit_index());
176        }
177        out
178    }
179}
180
181/// Context-wide sandbox policy the host hands the evaluator. The per-bit
182/// booleans are the capabilities the host *grants*; per-function
183/// *requirements* live on [`NativeFnGate`]. A call goes through iff every
184/// bit declared on the fn's gate is also set here — there is no per-name
185/// allowlist or global short-circuit, so a successful call proves that
186/// every bit on its gate was granted.
187///
188/// Beyond the capability bits, this struct also carries the runtime
189/// resource budgets (`max_steps`, `max_value_elements`) the evaluator
190/// enforces. The analyzer's static reachability check only reads the
191/// capability bits and ignores the budgets, but they live on the same
192/// struct so the evaluator's `Context` keeps a single sandbox-policy
193/// carrier (the budgets are `Option<_>` defaulting to "unbounded", so a
194/// `Capabilities` built purely for the analyzer is unaffected).
195///
196/// `#[non_exhaustive]`: future capability bits are added here without a
197/// breaking semver bump. External callers should prefer constructing via
198/// [`Capabilities::default`] / [`Capabilities::all_granted`] and mutating
199/// fields rather than relying on field-order struct literals.
200#[derive(Debug, Clone, Default)]
201#[non_exhaustive]
202pub struct Capabilities {
203    /// Filesystem reads (host fn that calls `std::fs::read*`, also the
204    /// policy bit consulted by `FilesystemModuleResolver`).
205    pub reads_fs: bool,
206    /// Filesystem writes (host fn that calls `std::fs::write*` /
207    /// `OpenOptions::write` / `create_dir*` / `remove_*`).
208    pub writes_fs: bool,
209    /// Network access (sockets, HTTP clients, DNS).
210    pub network: bool,
211    /// Wall / monotonic clock reads (`SystemTime::now`, `Instant::now`).
212    pub reads_clock: bool,
213    /// Process environment reads (`std::env::var`, `args`, etc.).
214    pub reads_env: bool,
215    /// Random number generation (any non-deterministic source).
216    pub uses_rng: bool,
217    /// Maximum number of AST nodes to process before aborting. `None`
218    /// is unbounded. Consulted only by the evaluator; the analyzer
219    /// ignores it.
220    pub max_steps: Option<u64>,
221    /// Maximum number of elements in a single List or Dict. `None` is
222    /// unbounded. Consulted only by the evaluator; the analyzer ignores
223    /// it.
224    pub max_value_elements: Option<usize>,
225}
226
227/// Evaluator-side resource-budget presets.
228///
229/// These profiles cover limits the in-process evaluator can enforce today.
230/// Host/VM limits such as wall-clock time, process memory, Wasmtime fuel, and
231/// final-output bytes live at their respective host boundaries.
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
233#[non_exhaustive]
234pub enum ResourceBudgetProfile {
235    /// Preserve historical behavior: no evaluator-side resource limit.
236    #[default]
237    Off,
238    /// Developer guardrails for local runs.
239    Dev,
240    /// Tighter guardrails for externally supplied source. This is not a VM
241    /// security boundary; use a wasm engine for hard untrusted execution.
242    Untrusted,
243}
244
245/// Evaluator-side resource budget.
246///
247/// `ResourceBudget` is deliberately separate from [`Capabilities`]:
248/// capabilities answer "may the program use this host authority?", while a
249/// budget answers "how much evaluator work/value growth is this host willing
250/// to pay for?". The current implementation still stores these two fields on
251/// [`Capabilities`] for compatibility; call [`Self::apply_to_capabilities`] to
252/// bridge the new model into the existing evaluator.
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
254#[non_exhaustive]
255pub struct ResourceBudget {
256    /// Maximum evaluator steps. `None` is unbounded.
257    pub max_steps: Option<u64>,
258    /// Maximum number of elements in a single List/Tuple/Dict. `None` is
259    /// unbounded.
260    pub max_value_elements: Option<usize>,
261}
262
263impl ResourceBudget {
264    pub const DEV_MAX_STEPS: u64 = 5_000_000;
265    pub const DEV_MAX_VALUE_ELEMENTS: usize = 100_000;
266    pub const UNTRUSTED_MAX_STEPS: u64 = 1_000_000;
267    pub const UNTRUSTED_MAX_VALUE_ELEMENTS: usize = 10_000;
268
269    /// No evaluator-side budget.
270    pub fn off() -> Self {
271        Self::default()
272    }
273
274    /// Local-development guardrails.
275    pub fn dev() -> Self {
276        Self {
277            max_steps: Some(Self::DEV_MAX_STEPS),
278            max_value_elements: Some(Self::DEV_MAX_VALUE_ELEMENTS),
279        }
280    }
281
282    /// Tighter evaluator guardrails for externally supplied source.
283    pub fn untrusted() -> Self {
284        Self {
285            max_steps: Some(Self::UNTRUSTED_MAX_STEPS),
286            max_value_elements: Some(Self::UNTRUSTED_MAX_VALUE_ELEMENTS),
287        }
288    }
289
290    pub fn from_profile(profile: ResourceBudgetProfile) -> Self {
291        match profile {
292            ResourceBudgetProfile::Off => Self::off(),
293            ResourceBudgetProfile::Dev => Self::dev(),
294            ResourceBudgetProfile::Untrusted => Self::untrusted(),
295        }
296    }
297
298    pub fn has_evaluator_limits(self) -> bool {
299        self.max_steps.is_some() || self.max_value_elements.is_some()
300    }
301
302    pub fn apply_to_capabilities(self, caps: &mut Capabilities) {
303        if let Some(max_steps) = self.max_steps {
304            caps.max_steps = Some(max_steps);
305        }
306        if let Some(max_value_elements) = self.max_value_elements {
307            caps.max_value_elements = Some(max_value_elements);
308        }
309    }
310}
311
312impl Capabilities {
313    /// Audit-visible "grant everything" preset: every capability bit
314    /// flipped, no step / value-size budget. The spec forbids an
315    /// implicit `Context::trusted()`-style shortcut; hosts that need
316    /// full grant must call this and read the resulting `Capabilities`
317    /// *as data*. See `docs/zh/guide/spec.md` §4.2.
318    ///
319    /// Note: opening filesystem reads also requires installing a
320    /// non-rejecting `FilesystemModuleResolver`. The `reads_fs` flag is
321    /// the policy bit; the resolver is the machinery that enforces it.
322    pub fn all_granted() -> Self {
323        Self {
324            reads_fs: true,
325            writes_fs: true,
326            network: true,
327            reads_clock: true,
328            reads_env: true,
329            uses_rng: true,
330            max_steps: None,
331            max_value_elements: None,
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn cap_bit_indices_are_stable() {
342        assert_eq!(CapabilityBit::ReadsFs.bit_index(), 0);
343        assert_eq!(CapabilityBit::WritesFs.bit_index(), 1);
344        assert_eq!(CapabilityBit::Network.bit_index(), 2);
345        assert_eq!(CapabilityBit::ReadsClock.bit_index(), 3);
346        assert_eq!(CapabilityBit::ReadsEnv.bit_index(), 4);
347        assert_eq!(CapabilityBit::UsesRng.bit_index(), 5);
348    }
349
350    #[test]
351    fn missing_bits_uses_canonical_labels() {
352        let gate = NativeFnGate {
353            reads_fs: true,
354            writes_fs: true,
355            network: true,
356            reads_clock: true,
357            reads_env: true,
358            uses_rng: true,
359        };
360        assert_eq!(
361            gate.missing_bits(&Capabilities::default()),
362            vec![
363                "reads_fs",
364                "writes_fs",
365                "network",
366                "reads_clock",
367                "reads_env",
368                "uses_rng",
369            ]
370        );
371        assert!(gate.missing_bits(&Capabilities::all_granted()).is_empty());
372    }
373
374    #[test]
375    fn resource_budget_profiles_are_stable() {
376        assert_eq!(
377            ResourceBudget::from_profile(ResourceBudgetProfile::Off),
378            ResourceBudget::off()
379        );
380        assert_eq!(
381            ResourceBudget::from_profile(ResourceBudgetProfile::Dev),
382            ResourceBudget {
383                max_steps: Some(ResourceBudget::DEV_MAX_STEPS),
384                max_value_elements: Some(ResourceBudget::DEV_MAX_VALUE_ELEMENTS),
385            }
386        );
387        assert_eq!(
388            ResourceBudget::from_profile(ResourceBudgetProfile::Untrusted),
389            ResourceBudget {
390                max_steps: Some(ResourceBudget::UNTRUSTED_MAX_STEPS),
391                max_value_elements: Some(ResourceBudget::UNTRUSTED_MAX_VALUE_ELEMENTS),
392            }
393        );
394    }
395
396    #[test]
397    fn resource_budget_does_not_grant_capabilities() {
398        let mut caps = Capabilities::default();
399        ResourceBudget::untrusted().apply_to_capabilities(&mut caps);
400        assert_eq!(caps.max_steps, Some(ResourceBudget::UNTRUSTED_MAX_STEPS));
401        assert_eq!(
402            caps.max_value_elements,
403            Some(ResourceBudget::UNTRUSTED_MAX_VALUE_ELEMENTS)
404        );
405        assert_eq!(
406            NativeFnGate {
407                reads_fs: true,
408                writes_fs: true,
409                network: true,
410                reads_clock: true,
411                reads_env: true,
412                uses_rng: true,
413            }
414            .missing_bits(&caps)
415            .len(),
416            6
417        );
418    }
419}