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}