Show HN: SafeScript, a safe version of JavaScript for executing untrusted code
Hacker News

Show HN: SafeScript, a safe version of JavaScript for executing untrusted code

JSON for Code

A safe computational subset of JavaScript. JSON gave the world a safe way to exchange data. SafeScript does the same for computation.

It's a strict subset of JavaScript - no eval, no new, no globals, no prototype access - that compiles to plain JS and PHP. Open a .safe file and you see code that's safe to run.

The compiler is ~48 KB minified (~12 KB gzipped). Ship it anywhere, verify locally, no trust needed.

SafeScript starts small, the same way JSON did. Anywhere you need a safe handler for data or events:

  • Webserver handlers - route logic, request transforms, validation rules submitted by users or tenants
  • onclick and event handlers - user-authored browser event handlers that can't escape their sandbox
  • Plugin scripts - extend an app with user-submitted logic without risking the host
  • Formulas and expressions - spreadsheet-style computed fields, pricing rules, scoring functions
  • Bot and agent tools - callable tools for AI agents that are safe by construction (see Safebots)
  • Config with logic - when JSON isn't enough but arbitrary JS is too much
// Node.js
const SafeScript = require('safescript');
const result = await SafeScript.run('return x + y;', { x: 10, y: 20 });
// → 30

// Safe globals are available by default - no injection needed
await SafeScript.run('return Math.floor(3.7);'); // → 3
await SafeScript.run('return JSON.parse("{}");'); // → {} (null-prototype, __proto__ stripped)
await SafeScript.run('return Object.keys({a:1});'); // → ['a']

// Math.random is deterministic by default (mulberry32 PRNG, seed 0)
await SafeScript.run('return Math.random();'); // → same value every time

SafeScript.run('return n * 2;', { n: 21 }).then(function(result) {
  // → 42
});

Safe Files and Headers

SafeScript files use the .safe extension and application/safescript MIME type. A .safe file is self-contained - it declares what capabilities it needs at the top, and the host provides them.

/**
 * @safescript 0.2
 * @needs request: Request
 * @needs db: Database
 * @uses logger: Logger
 * @env browser, node
 */
var body = request.json();
if (logger) logger.log('received', body);
return db.store(body);

The header is an optional /** */ block comment with five directives:

  • @safescript - language version
  • @needs <name>: <type> - required capability; the host must provide it or refuse to run the file
  • @uses <name>: <type> - optional capability; the host provides it if available, the script checks with if (name)
  • @type <name> { method1, method2 } - declares a return type and the methods the script intends to call on it
  • @env <env1>, <env2> - constrains which environments can run this file (optional, comma-separated)

@type declarations tell the host which methods on return types the script intends to call. They're purely declarative - they don't change runtime enforcement, but they enable pre-flight validation at load() time.

/**
 * @safescript 0.2
 * @needs fetch: function
 * @needs log: function
 * @type Response { json, text, status }
 * @type Stream { get, set, fields }
 */
var resp = fetch('/api/data');
var data = resp.json();
log(data);

When load() processes this header, it checks:

  • Every @type name is registered with the host (via SafeScript.lib())
  • Every declared method is in the host's allowMethods whitelist

This catches mismatches early - before the script runs - rather than failing at the first method call. If a script declares @type Response { json, text, status } but the host only allows json and text, load() throws immediately.

The File Is the Security Boundary

A .safe file is a unit of trust - one set of declarations, one sandbox boundary. Everything about its security posture is visible in a single file:

  • What it needs (@needs) - the injected capabilities
  • What it optionally uses (@uses) - degradable capabilities
  • What return types it touches (@type) - methods on host-provided objects
  • What environment it expects (@env) - runtime constraints

If you want two functions with different access patterns, you put them in two .safe files. Each file gets its own scope, its own type declarations, and its own sandbox. The file IS the security boundary. This is by design.

SafeScript protects the host's environment - it ensures that user-submitted code can only call what the host explicitly allows. User protection (ensuring the host acts in the user's interest) comes from a different layer entirely - attestations about the host, not constraints in SafeScript. See Safebots layers for how these layers compose.

No Imports, Only Declarations

A .safe file declares what it needs. It doesn't import, require, or fetch anything. The host reads the declarations and builds the scope - where the host gets the implementations is its business.

A Node host might resolve them from node_modules. A browser host might use an import map or a CDN. A sandboxed environment might draw from a curated whitelist. The .safe file doesn't know or care.

This is a deliberate departure from require() and import. Package managers are an implementation detail of a particular host, not a top-level concept in the language. A .safe file that depends on npm to function is not truly portable. A .safe file that declares @needs fetch: http.Fetch works everywhere a host can provide an HTTP client.

This also avoids inheriting the supply chain attack surface of package managers. SafeScript's security story starts at the file boundary - you read the header, you see what the code can touch, you decide whether to run it.

const SafeScript = require('safescript');

// 1. Parse the header
const caps = SafeScript.capabilities(source);
// → { version: '0.2', env: ['browser', 'node'],
// needs: [{name: 'request', type: 'Request'}, ...],
// uses: [{name: 'logger', type: 'Logger'}] }

// 2. Check that you can satisfy the requirements
const check = SafeScript.checkScope(source, myScope);
if (!check.ok) throw new Error('Missing: ' + check.missing.join(', '));

// 3. Run with the scope you built
const result = await SafeScript.run(source, myScope);

Four Security Barriers

Four security barriers, defense in depth:

  • Token/grammar allowlist - The parser accepts only a subset of JavaScript. No eval, new, delete, class, import, async, await, template literals, or access to globalThis / window / document. The AST is reconstructed from scratch - the original source text never appears in the output.
  • Runtime property guards - Bracket access (obj[key]) is rewritten to __at(obj, key) / __set(obj, key, val), which block prototype-chain properties (constructor, __proto__, prototype, caller, callee) at runtime, even when keys are computed dynamically.
  • Global shadowing + safe globals - Every native global is declared as undefined inside the compiled function, then a curated set of safe globals is injected automatically: frozen facade objects for Math, JSON, Object, Array, String, Number, error constructors, and standalone functions like parseInt and encodeURI. These facades expose only safe static methods - never raw constructors or mutable prototypes. Additional capabilities are injected via the scope object. Code always runs in "use strict".
  • Execution guards + hooks - Compile-time injected __check() calls enforce timeout and operation limits (hardcoded ceilings: 60s, 100M ops), preventing infinite loops and infinite recursion. Hooks intercept every call to injected scope functions, letting the host throttle resources at the boundary.

With an empty scope, SafeScript is pure computation with safe globals - deterministic math, JSON handling, type checking - but no I/O, no side effects, no access to anything outside the function. Additional capabilities are injected via the scope object using an object-capability model.

Execution Model

A .safe file is a function body. The compiler wraps it in a function, the host calls that function with a scope, and the code uses return to hand a value back. run() always returns a Promise.

// Return a computed value
await SafeScript.run('return price * qty;', { price: 9.99, qty: 3 });
// → 29.97

// Return a function (the function itself is async)
var double = await SafeScript.run('return function(n) { return n * 2; };', {});
await double(21);
// → 42

// Return an object - the "module" pattern
var mod = await SafeScript.run(`
  var count = 0;
  return {
    inc: function() { count = count + 1; return count; },
    get: function() { return count; }
  };
`, {});
await mod.inc();
await mod.inc();
await mod.get();
// → 2

// Side-effect only (write to an injected capability, no return needed)
await SafeScript.run('db.store(record);', { db: myDb, record: data });

There is no export, no module.exports, and no implicit last-expression return. return makes intent explicit and keeps .safe files readable.

SafeScript uses an auto-async execution model. The compiler automatically:

  • Emits every function (declarations, expressions, arrows, method shorthands) as async
  • Wraps every function call with await

This means async and await are not keywords in SafeScript - they are blocked. The compiler handles them transparently. This has two important consequences:

  • Promises resolve automatically. When injected objects return Promises, they're awaited at every call site. No unresolved Promises can leak through.
  • Sequential execution is guaranteed. There are no concurrent in-flight operations inside a single SafeScript execution. Throughput comes from running many SafeScript instances in parallel (like PHP-FPM workers), not from concurrency within one.
  • Transparent cross-context proxying. Because every external call is awaited, SafeScript code can run in a WebWorker while the scope objects proxy calls to the main thread via postMessage. The SafeScript source doesn't change - it was already written as synchronous-looking code, and the compiler already awaits every call. This means a host like Q.Safebox can move untrusted script execution off the main thread with zero code changes to the .safe files.
// Same SafeScript source works identically whether scope is local or proxied:
var el = createElement("div")

Hooks

// 100) throw new Error('too many calls');

// Short-circuit with cached/default value
if (info.name === 'fetch' && rateLimited) return { value: null };

// Transform arguments
if (info.name === 'db.query') return { args: [sanitizeSQL(info.args[0])] };

// Return undefined to proceed normally
},
afterCall: function(info) {
// info = { name, args, result, callCount, elapsed }

// Check response size
if (JSON.stringify(info.result).length > 1000000) {
  throw new Error('response too large');
}

// Transform result
return { value: redact(info.result) };
}
}
});

beforeCall(info) - called before each scope function invocation. Returns:

  • undefined - proceed normally
  • false - suppress the call (returns undefined to the script)
  • { value: x } - short-circuit, return x without calling (including null for graceful degradation)
  • { args: [...] } - transform arguments, then call
  • throw - kill execution

afterCall(info) - called after each scope function returns. Same return protocol plus access to info.result.

Hooks wrap functions recursively through nested scope objects. If you inject { db: { query: fn, insert: fn } }, hooks see "db.query" and "db.insert" as the name.

Composition

SafeScript files don't import each other. Composition happens at the host level - run one SafeScript to produce utilities, then inject the result into another:

// Build a utility library
var utils = await SafeScript.run(utilsSource, { Math });

// Inject it into a handler
var result = await SafeScript.run(handlerSource, { utils, db, request });

This keeps each .safe file self-contained while allowing the host to compose them freely. The host controls the dependency graph, not the scripts.

Safe Globals

SafeScript provides a set of frozen facade objects available in every execution - no injection needed. These are safe subsets of their JavaScript counterparts: only static methods, no constructors, no mutable prototypes.

Global What's available
Object keys, values, entries, assign, freeze, isFrozen, getOwnPropertyNames, create (null only)
Array isArray, from, of
String fromCharCode, fromCodePoint
Number isInteger, isFinite, isNaN, parseInt, parseFloat, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, EPSILON, MAX_VALUE, MIN_VALUE, POSITIVE_INFINITY, NEGATIVE_INFINITY, NaN
Math All standard methods. Math.random is deterministic by default (see below).
JSON parse (sanitized - output uses null-prototype objects, __proto__ keys stripped), stringify
Error, TypeError, RangeError, SyntaxError, ReferenceError Constructors (usable with throw)
Standalone parseInt, parseFloat, isNaN, isFinite, encodeURI, decodeURI, encodeURIComponent, decodeURIComponent
Constants NaN, Infinity

All facade objects are deeply frozen. Attempts to modify them (e.g. Math.PI = 3) silently fail in strict mode. Object.create is restricted to Object.create(null) - passing any other argument throws. JSON.parse output is deep-walked to convert all objects to null-prototype objects and strip any __proto__ keys. This prevents prototype pollution attacks through parsed JSON data.

Math.random uses a mulberry32 PRNG seeded per invocation. By default, the seed is 0, so every call to run() produces the same sequence of random numbers. This makes SafeScript executions fully reproducible.

// Deterministic - same result every time
await SafeScript.run('return Math.random();');
// → always 0.2267...

// Custom seed
await SafeScript.run('return Math.random();', {}, { seed: 42 });
// → always 0.0020...

// True randomness (requires host approval)
await SafeScript.run('return Math.random();', {}, { seed: null, env: { Random: true } });

The seed option controls behavior:

seed value Behavior
omitted / undefined Deterministic, seed = 0
any integer Deterministic, that seed
null True Math.random - requires env: { Random: true }

Setting seed: null without env: { Random: true } throws: SafeScript: seed: null requires env: { Random: true } (nondeterministic RNG needs host approval). This ensures nondeterminism is always an explicit host decision.

Hosts can register named libraries that load()

Read on Hacker News ↗ ← Back to News

Comments

No comments yet. Start the discussion.