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:
| Limit | Default | When it is hit |
|---|---|---|
| JS heap | 256 MB | The VM stops. |
| Interpreter stack | 4096 KB (also the maximum) | Deep recursion throws a RangeError, which the app can catch. |
| Time to evaluate the bundle | 10 s | The evaluation is cut off. |
| Time of any other call into JS | 2 s | The call is cut off; the app goes on. |
| Nodes kept | 200 000 | The VM stops. |
| Changes at once (queued operations) | 8 000 000 values | The VM stops. |
| Operations in one canvas drawing | 1 000 000 | The 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:
-
evalis removed, and theFunction,AsyncFunction,GeneratorFunctionandAsyncGeneratorFunctionconstructors 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 changeArray.prototypefor everyone else.Error.prototypestays writable, so error subclasses can setthis.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.
| Key | Type | Description |
|---|---|---|
hosts | string[] | Hosts the app may reach with any method and scheme (http, https, ws, wss). |
http | object[] | Finer grants, one per host. |
http[].host | string | The host (required). |
http[].methods | string[] | Allowed methods, such as ["GET", "HEAD"]. Empty or missing: any. |
http[].schemes | string[] | Allowed schemes. Empty or missing: any of http, https, ws, wss. |
http[].pathPrefix | string | The 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
| Key | Type | Description |
|---|---|---|
hosts | string[] | 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
| Request | Result |
|---|---|
fetch | The promise rejects; the request never reaches the network. |
| Remote image | The image fails with onError. |
| Video source, web view source, status bar change | Nothing 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 buildandnsh packagewrite it next to the bundle ascapabilities.json;nsh publishputs it in the signed package header, covered by the signature;nsh devsends it with every build, so development enforces the same rules. Changingcapabilitiesreloads 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
| Layer | Enforced by | Set by |
|---|---|---|
| Limits (heap, stack, time, nodes) | the runtime, per VM | defaults; host app per view |
| Hardening | the runtime, on load | always on outside development |
| Capabilities | the runtime (fetch, images, video, web views, status bar) | the app (nsh.json) and the host app (grant) |
| Code integrity | signature verification | nsh publish; the host app’s trusted roots |