Docs

Sandbox and Capabilities

An app written in Rust is native code compiled into the app that ships it, as the rest of that app is, and runs with its trust; its calls into NSH’s services (fetch, images, web views, the status bar) go through the same native checks as TSX’s, under what a host grants. The TSX and JavaScript an app runs, its own or a signed package it downloads, run in a QuickJS VM per view, and everything they 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

Limits bound a VM: they apply to TSX and JavaScript. An app embedded in another app runs within limits its host app decides. An app on its own (packaged with nsh package, the web included, or run with nsh run) is your own code, as a native app is: it uses the memory and time the device gives it, and its interpreter stack of 4096 KB turns deep recursion into a RangeError the app can catch.

An embedded app 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 set the limits per view: NshAppView.limits on iOS, NshView.setLimits on Android, AppOptions::limits / AppView::set_limits in a GPUI app. They apply from the next load.

To see in development what a TSX app does within a host app’s limits, run it with --limits: nsh run App.tsx --limits uses the defaults above, and nsh run --limits memory=128MB,call=500ms sets those two as a host app would, the rest keeping their defaults.

#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 test (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/" }],
      "sockets": ["feed.example.com:7000"]
    },
    "webview": { "hosts": ["*.example.com"] },
    "statusBar": true,
    "clipboard": true
  }
}

An app without "capabilities" may do everything: a standalone app is its developer’s own, and nothing is restricted. Raw TCP sockets are granted by name in every app, through network.sockets. 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. A signed package from a newer NSH may declare capabilities an older runtime does not know: they are not granted there.

#network

Covers fetch (including every redirect), WebSockets, remote images (<Image>, placeholder, canvas images, Image.getSize / Image.prefetch), video sources and raw TCP sockets (net.connect).

KeyTypeDescription
hostsstring[]Hosts the app may reach with any method and scheme (http, https, ws, wss).
httpobject[]Finer grants: a host, narrowed by any of the keys below.
http[].hoststringThe host or host pattern (required).
http[].methodsstring[]Allowed methods, such as ["GET", "HEAD"]. Missing: any.
http[].schemesstring[]Allowed schemes (scheme: one, as a string). Missing: any of http, https, ws, wss.
http[].portnumberThe port; a URL without one has its scheme’s default (443 for https). Missing: any.
http[].pathsstring[]Exact paths, such as ["/v1/quote"] (the query is not part of the path).
http[].pathPrefixesstring[]Path prefixes covering whole segments: /v1 covers /v1 and /v1/quote, /v1/ what follows it. pathPrefix (one, as a string) and path_prefixes read the same.
socketsstring[]Raw TCP destinations for net.connect, each an exact "host:port" ("[::1]:9000" for IPv6).

An http rule covers every path when it lists no paths or pathPrefixes, and with both a path matching either.

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.

A WebSocket handshake is an HTTP GET: ws:// is checked as http:// and wss:// as https://, so the hosts and http grants that allow a GET of the URL allow the WebSocket. A raw socket reaches past every HTTP rule, so sockets lists each host and port exactly, and only sockets grants them.

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 (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 of pages and dialogs in nsh/base, and nsh.json’s "statusBar" default).

#clipboard

"clipboard": true lets the app read and write the system clipboard (nsh/clipboard). Copying from a selectable text’s edit menu is the user’s own act and needs no capability.

#Storage

Every app may keep its own data on the device with nsh/storage, with no capability: the storage holds only that app’s data, and the embedding app decides where it is kept.

#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.
ClipboardwriteText and writeImage do nothing; readText resolves "", readImage null, readFiles [], and hasText and hasImage false.

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, in Rust (nsh_platform_ios::AppView)
let grant = nsh_engine::Capabilities::from_json(&serde_json::json!({
    "network": { "hosts": ["api.example.com"] }
}))?;
app.set_granted_capabilities(Some(grant));
// 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 VMthe host app per view (embedded apps)
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