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}