Guide

Sandbox and Capabilities

Every nsh app runs in its own QuickJS VM, and everything an app can do outside that VM goes through nsh’s native runtime. That makes it possible to run code you did not write, such as third-party mini apps or downloaded updates, under rules the host app controls:

  • Limits bound the memory, time and native resources one app can use.
  • Hardening removes the ways to turn strings into code in release builds.
  • Capabilities list what an app may reach: which hosts it may fetch from, which pages a web view may show, whether it may change the status bar. The app declares them; a host app may grant less.

A bundle holds only the app’s code: the reactive graph, the renderer and every module the app imports are native. So nothing in JS is trusted, and every limit and check is enforced in Rust; there is no JS code an app could patch to get around them.

#Limits

Each VM has these limits:

LimitDefaultWhen it is hit
JS heap256 MBThe VM stops.
Interpreter stack4096 KB (also the maximum)Deep recursion throws a RangeError, which the app can catch.
Time to evaluate the bundle10 sThe evaluation is cut off.
Time of any other call into JS2 sThe call is cut off; the app goes on.
Nodes kept200 000The VM stops.
Changes at once (queued operations)8 000 000 valuesThe VM stops.
Operations in one canvas drawing1 000 000The VM stops.

Time is budgeted per call, not per app. A “call” is one thing the runtime asks JS to do: handle one event (including the rendering it causes), bind one list row, run one timer, run one frame’s requestAnimationFrame callbacks, drain the microtask queue. A long-running app never accumulates time toward a limit; only a single call that does not return in time is interrupted. Calls nested inside another share the outer call’s budget.

An interrupted call ends with an exception that no catch can swallow, and the error is reported with the budget it exceeded:

the script was interrupted: a call into JS exceeded its budget of 2000 ms

The VM keeps running: the next event, timer or frame runs normally.

Running out of memory or going over a quota stops the VM. The error is reported once (to the host app’s error handler, and in development builds over the app), no JS runs after that, and the views stay as they were. The node and operation quotas exist because native views and queued changes live in native memory, which the JS heap limit does not cover: without them, an app could exhaust the device’s memory through the UI.

Host apps can change the limits per view: NshAppView.limits on iOS, NshView.setLimits on Android. They apply from the next load.

#Hardening

Release builds (everything not loaded from a dev server) are hardened when they load:

  • eval is removed, and the Function, AsyncFunction, GeneratorFunction and AsyncGeneratorFunction constructors throw. nsh runs no code made from strings:

    TypeError: the Function constructor is disabled: nsh runs no code from strings (development builds allow it)
  • The prototypes all modules share (Object, Array, Function, String, Number, Promise, Map, typed arrays and the rest) are frozen, so one module (or one library) cannot change Array.prototype for everyone else. Error.prototype stays writable, so error subclasses can set this.name.

Freezing prototypes would normally break a common pattern, the “override mistake”: assigning Sub.prototype.constructor = Sub or obj.toString = … throws when the inherited property is frozen. nsh turns the commonly overridden members (constructor, toString, valueOf, toJSON, …) into accessors whose setter defines the property on the object assigned to, so such code (and libraries like axios that use it) keeps working.

Bundles served by nsh dev / nsh run (with or without --release) are not hardened, so you can debug with eval. Test the release behavior with nsh trace (which hardens) or a packaged build before shipping code that relies on dynamic code.

#Capabilities

An app declares what it may reach outside its VM in nsh.json:

{
  "name": "Quotes",
  "id": "com.example.quotes",
  "version": "1.0.0",
  "build": 3,
  "capabilities": {
    "network": {
      "hosts": ["api.example.com", "*.cdn.example.com"],
      "http": [{ "host": "quotes.example.com", "methods": ["GET"], "pathPrefix": "/v1/" }]
    },
    "webview": { "hosts": ["*.example.com"] },
    "statusBar": true
  }
}

An app without "capabilities" may do everything: a standalone app is its developer’s own, and nothing is restricted. An app with "capabilities" may do only what it lists: everything else is denied. Unknown keys are build errors, so a typo is caught when you build instead of becoming a missing permission at runtime.

#network

Covers fetch (including every redirect), remote images (<Image>, defaultSource, canvas images, Image.getSize / Image.prefetch) and video sources.

KeyTypeDescription
hostsstring[]Hosts the app may reach with any method and scheme (http, https, ws, wss).
httpobject[]Finer grants, one per host.
http[].hoststringThe host (required).
http[].methodsstring[]Allowed methods, such as ["GET", "HEAD"]. Empty or missing: any.
http[].schemesstring[]Allowed schemes. Empty or missing: any of http, https, ws, wss.
http[].pathPrefixstringThe URL’s path must start with it.

Host patterns are * (any host), *.example.com (any subdomain of example.com, not example.com itself) or an exact host name. Matching ignores case and the port. The host is taken after the last @, so https://api.example.com@evil.io/ is checked as evil.io.

asset: URIs (the app’s own files) are always allowed; images and videos may also use data: URIs and relative paths.

#webview

KeyTypeDescription
hostsstring[]Hosts a web view may show and navigate to (http and https). Without webview, the app may not show web pages at all.

Top-level navigations inside the web view are checked too, by the platform’s navigation delegate, before they load. The page’s own subresources (its scripts, images, XHRs) are the web content’s business and are not checked. about:blank and inline HTML (source={{ html }}) need only web views to be granted at all: "webview": { "hosts": [] } allows them without allowing any remote page.

#statusBar

"statusBar": true lets the app change the status bar (the StatusBar component and its functions in nsh/ui/mobile).

#What a refusal looks like

RequestResult
fetchThe promise rejects; the request never reaches the network.
Remote imageThe image fails with onError.
Video source, web view source, status bar changeNothing is shown or changed.

Each refusal is logged once, saying which key to add:

fetch GET https://evil.example.org/x is not granted: add `evil.example.org` to capabilities.network in nsh.json

Redirects are followed by nsh, not by the platform’s HTTP stack, so every hop is checked against the capabilities. (A streamed request body is not sent twice, so a 307 / 308 redirect of a streamed upload fails.)

#Where capabilities travel

The declaration goes with the app wherever it goes:

  • nsh build and nsh package write it next to the bundle as capabilities.json;
  • nsh publish puts it in the signed package header, covered by the signature;
  • nsh dev sends it with every build, so development enforces the same rules. Changing capabilities reloads the app instead of hot-patching it.

#Grants: what a host app allows

A host app that embeds nsh can grant at most some capabilities, in the same JSON form:

// iOS
try app.grantCapabilities(#"{ "network": { "hosts": ["api.example.com"] } }"#)
// Android
nsh.setGrantedCapabilities("{ \"network\": { \"hosts\": [\"api.example.com\"] } }");

A request must be allowed by both the app’s declaration and the host’s grant. With the grant above, an app that declares "hosts": ["*"] can still only reach api.example.com, and cannot show web pages or change the status bar. Without a grant (nil / null, the default), what each app declares applies.

Grants apply from the next load. A typical setup for third-party mini apps: grant each one only its own API host, run it from a signed package whose declaration you reviewed, and keep the default limits or lower them.

#Summary

LayerEnforced bySet by
Limits (heap, stack, time, nodes)the runtime, per VMdefaults; host app per view
Hardeningthe runtime, on loadalways on outside development
Capabilitiesthe runtime (fetch, images, video, web views, status bar)the app (nsh.json) and the host app (grant)
Code integritysignature verificationnsh publish; the host app’s trusted roots

#See also