Skip to main content

rpc/natives/
render.rs

1//! Restricted render-only native surface for template evaluation.
2//!
3//! Templates are per-user nomiscript source that must NOT be able to mutate
4//! data or read secrets — above all the per-user JWT private key reachable via
5//! `get-config` (Slice B places it in the same per-user DB the eval channel
6//! reads). The render surface is therefore a strict WHITELIST applied at BOTH
7//! levels:
8//!
9//!   * [`render_compiler_specs`] — the compiler only knows the allowlisted
10//!     read-only financial natives plus the draft natives, so a template that
11//!     names anything else is a COMPILE error (it never reaches the linker).
12//!   * [`link_render`] — the linker registers only the infra natives, the
13//!     allowlisted read modules' fns, and the draft natives. The secret/auth
14//!     modules (`config`, `user`, `ssh_key`) and every mutator are NOT linked
15//!     at all, so even a hypothetical compiler-spec leak has nothing to call.
16//!
17//! Whitelist, not blacklist: a newly added native is excluded by default and
18//! must be explicitly added here, so a future sensitive native cannot silently
19//! become reachable from templates.
20
21use nomiscript::HostFnSpec;
22use wasmtime::Linker;
23
24use crate::session::SessionData;
25
26/// The only non-draft natives a template may call: read-only financial lookups,
27/// pure value conversion, and the session-user-id accessor. Every entry is
28/// verified read-only — no mutator, no config/secret reader, no auth surface.
29/// `convert-commodity` only reads the prices table (verified: it issues
30/// `SELECT … prices …`, never a write).
31pub const RENDER_NATIVE_ALLOWLIST: &[&str] = &[
32    // Infra / meta. NOTE: `get-version`/`get-build-date` are deliberately
33    // excluded — they live in the `config` module alongside the catastrophic
34    // `get-config`, and `link_render` never registers that module. Keeping them
35    // off the allowlist means the entire config surface stays unlinked, so the
36    // secret reader has nothing to bind to even if a spec leaked. Templates have
37    // no use for build metadata anyway.
38    "rpc-protocol-version",
39    "rpc-session-user-id",
40    // Account reads.
41    "account-count",
42    "list-accounts",
43    "get-account",
44    "get-balance",
45    "get-account-commodities",
46    "account-balance",
47    "list-accounts-for-manage",
48    "get-account-for-manage",
49    // Commodity reads + pure conversion.
50    "list-commodities",
51    "get-commodity",
52    "convert-commodity",
53    // Transaction reads.
54    "list-transactions",
55    "get-transaction",
56    "get-transaction-tag",
57    // Split reads.
58    "list-splits",
59    "list-splits-by-transaction",
60    "get-split-tag",
61    // Reports (read-only).
62    "balance-report",
63    "activity-report",
64    "category-breakdown",
65    // Draft natives (render-only side effects into the accumulator).
66    "set-draft-note",
67    "set-draft-date",
68    "draft-split",
69    "draft-tag",
70    "draft-split-tag",
71];
72
73/// Compiler specs the render surface exposes: the allowlisted subset of
74/// [`all_compiler_specs`](super::all_compiler_specs). Filtering the single
75/// canonical registry (rather than hand-maintaining a parallel list) keeps the
76/// render surface in lock-step with the org source — a renamed native drops out
77/// automatically rather than silently mismatching the linker.
78#[must_use]
79pub fn render_compiler_specs() -> Vec<HostFnSpec> {
80    super::all_compiler_specs()
81        .into_iter()
82        .filter(|spec| {
83            RENDER_NATIVE_ALLOWLIST.contains(&spec.nomi_name.to_ascii_lowercase().as_str())
84        })
85        .collect()
86}
87
88/// Links ONLY the render-safe host fns onto `linker`: infra, the allowlisted
89/// read fns, and the draft natives. The dual gate is FULL here — a dangerous
90/// native is blocked at both levels. First, the `config`, `user`, and `ssh_key`
91/// modules are never registered at all (the secret/credential/auth surface has
92/// nothing to bind to). Second, the account/commodity/split/transaction
93/// mutators are excluded by linking each module's `register_readonly` variant,
94/// so `create-*` / `update-*` / `delete-*` / `set-*-tag` imports are never bound
95/// either. So even a hypothetical compiler-spec leak finds no linked target.
96/// `env.log` is bound as a silent no-op (the import must resolve, but template
97/// output is discarded).
98pub fn link_render(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
99    super::meta::register(linker)?;
100    super::env_io::register_silent(linker)?;
101    super::raise::register(linker)?;
102    super::catch_each::register(linker)?;
103    super::account::register_readonly(linker)?;
104    super::commodity::register_readonly(linker)?;
105    super::report::register(linker)?;
106    super::split::register_readonly(linker)?;
107    super::transaction::register_readonly(linker)?;
108    super::template::register(linker)?;
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114    use crate::session::SessionData;
115    use crate::wasm::{EngineOpts, build_engine};
116    use wasmtime::Linker;
117
118    /// Whether the render linker has a host fn bound at `nomi.<import>`.
119    /// Independent of the compiler gate: proves the linker itself never binds a
120    /// dangerous import, so even a spec leak finds no target.
121    fn render_binds(import: &str) -> bool {
122        let engine = build_engine(EngineOpts::baseline().with_fuel()).expect("engine");
123        let mut linker: Linker<SessionData> = Linker::new(&engine);
124        super::link_render(&mut linker).expect("link_render");
125        linker
126            .get(&mut dummy_store(&engine), "nomi", import)
127            .is_ok()
128    }
129
130    fn dummy_store(engine: &wasmtime::Engine) -> wasmtime::Store<SessionData> {
131        use crate::ctx::ScriptCtx;
132        use std::sync::{Arc, Mutex};
133        use uuid::Uuid;
134        wasmtime::Store::new(
135            engine,
136            SessionData::for_render(
137                ScriptCtx::new(Uuid::nil()),
138                Arc::new(Mutex::new(String::new())),
139            ),
140        )
141    }
142
143    #[test]
144    fn render_linker_does_not_bind_mutators_or_secrets() {
145        for dangerous in [
146            "transaction_create_transaction",
147            "transaction_delete_transaction",
148            "account_create_account",
149            "account_set_account_tag",
150            "commodity_create_commodity",
151            "split_set_split_tag",
152            "config_get_config",
153            "config_set_config",
154            "user_verify_user_password",
155            "ssh_key_lookup_user_by_ssh_key",
156        ] {
157            assert!(
158                !render_binds(dangerous),
159                "render linker must NOT bind dangerous import '{dangerous}'"
160            );
161        }
162    }
163
164    #[test]
165    fn render_linker_binds_allowlisted_reads() {
166        assert!(
167            render_binds("account_get_account"),
168            "render linker must bind the read native 'account_get_account'"
169        );
170    }
171}