Lines
100 %
Functions
61.54 %
Branches
//! Restricted render-only native surface for template evaluation.
//!
//! Templates are per-user nomiscript source that must NOT be able to mutate
//! data or read secrets — above all the per-user JWT private key reachable via
//! `get-config` (Slice B places it in the same per-user DB the eval channel
//! reads). The render surface is therefore a strict WHITELIST applied at BOTH
//! levels:
//! * [`render_compiler_specs`] — the compiler only knows the allowlisted
//! read-only financial natives plus the draft natives, so a template that
//! names anything else is a COMPILE error (it never reaches the linker).
//! * [`link_render`] — the linker registers only the infra natives, the
//! allowlisted read modules' fns, and the draft natives. The secret/auth
//! modules (`config`, `user`, `ssh_key`) and every mutator are NOT linked
//! at all, so even a hypothetical compiler-spec leak has nothing to call.
//! Whitelist, not blacklist: a newly added native is excluded by default and
//! must be explicitly added here, so a future sensitive native cannot silently
//! become reachable from templates.
use nomiscript::HostFnSpec;
use wasmtime::Linker;
use crate::session::SessionData;
/// The only non-draft natives a template may call: read-only financial lookups,
/// pure value conversion, and the session-user-id accessor. Every entry is
/// verified read-only — no mutator, no config/secret reader, no auth surface.
/// `convert-commodity` only reads the prices table (verified: it issues
/// `SELECT … prices …`, never a write).
pub const RENDER_NATIVE_ALLOWLIST: &[&str] = &[
// Infra / meta. NOTE: `get-version`/`get-build-date` are deliberately
// excluded — they live in the `config` module alongside the catastrophic
// `get-config`, and `link_render` never registers that module. Keeping them
// off the allowlist means the entire config surface stays unlinked, so the
// secret reader has nothing to bind to even if a spec leaked. Templates have
// no use for build metadata anyway.
"rpc-protocol-version",
"rpc-session-user-id",
// Account reads.
"account-count",
"list-accounts",
"get-account",
"get-balance",
"get-account-commodities",
"account-balance",
"list-accounts-for-manage",
"get-account-for-manage",
// Commodity reads + pure conversion.
"list-commodities",
"get-commodity",
"convert-commodity",
// Transaction reads.
"list-transactions",
"get-transaction",
"get-transaction-tag",
// Split reads.
"list-splits",
"list-splits-by-transaction",
"get-split-tag",
// Reports (read-only).
"balance-report",
"activity-report",
"category-breakdown",
// Draft natives (render-only side effects into the accumulator).
"set-draft-note",
"set-draft-date",
"draft-split",
"draft-tag",
"draft-split-tag",
];
/// Compiler specs the render surface exposes: the allowlisted subset of
/// [`all_compiler_specs`](super::all_compiler_specs). Filtering the single
/// canonical registry (rather than hand-maintaining a parallel list) keeps the
/// render surface in lock-step with the org source — a renamed native drops out
/// automatically rather than silently mismatching the linker.
#[must_use]
pub fn render_compiler_specs() -> Vec<HostFnSpec> {
super::all_compiler_specs()
.into_iter()
.filter(|spec| {
RENDER_NATIVE_ALLOWLIST.contains(&spec.nomi_name.to_ascii_lowercase().as_str())
})
.collect()
}
/// Links ONLY the render-safe host fns onto `linker`: infra, the allowlisted
/// read fns, and the draft natives. The dual gate is FULL here — a dangerous
/// native is blocked at both levels. First, the `config`, `user`, and `ssh_key`
/// modules are never registered at all (the secret/credential/auth surface has
/// nothing to bind to). Second, the account/commodity/split/transaction
/// mutators are excluded by linking each module's `register_readonly` variant,
/// so `create-*` / `update-*` / `delete-*` / `set-*-tag` imports are never bound
/// either. So even a hypothetical compiler-spec leak finds no linked target.
/// `env.log` is bound as a silent no-op (the import must resolve, but template
/// output is discarded).
pub fn link_render(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
super::meta::register(linker)?;
super::env_io::register_silent(linker)?;
super::raise::register(linker)?;
super::catch_each::register(linker)?;
super::account::register_readonly(linker)?;
super::commodity::register_readonly(linker)?;
super::report::register(linker)?;
super::split::register_readonly(linker)?;
super::transaction::register_readonly(linker)?;
super::template::register(linker)?;
Ok(())
#[cfg(test)]
mod tests {
use crate::wasm::{EngineOpts, build_engine};
/// Whether the render linker has a host fn bound at `nomi.<import>`.
/// Independent of the compiler gate: proves the linker itself never binds a
/// dangerous import, so even a spec leak finds no target.
fn render_binds(import: &str) -> bool {
let engine = build_engine(EngineOpts::baseline().with_fuel()).expect("engine");
let mut linker: Linker<SessionData> = Linker::new(&engine);
super::link_render(&mut linker).expect("link_render");
linker
.get(&mut dummy_store(&engine), "nomi", import)
.is_ok()
fn dummy_store(engine: &wasmtime::Engine) -> wasmtime::Store<SessionData> {
use crate::ctx::ScriptCtx;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
wasmtime::Store::new(
engine,
SessionData::for_render(
ScriptCtx::new(Uuid::nil()),
Arc::new(Mutex::new(String::new())),
),
)
#[test]
fn render_linker_does_not_bind_mutators_or_secrets() {
for dangerous in [
"transaction_create_transaction",
"transaction_delete_transaction",
"account_create_account",
"account_set_account_tag",
"commodity_create_commodity",
"split_set_split_tag",
"config_get_config",
"config_set_config",
"user_verify_user_password",
"ssh_key_lookup_user_by_ssh_key",
] {
assert!(
!render_binds(dangerous),
"render linker must NOT bind dangerous import '{dangerous}'"
);
fn render_linker_binds_allowlisted_reads() {
render_binds("account_get_account"),
"render linker must bind the read native 'account_get_account'"