Compartments

Compartmentalization of host behavior hooks for JS.

Stage: 1

Champions:

  • Bradley Farias, GoDaddy
  • Mark S. Miller, Agoric
  • Caridy Patiño, Salesforce
  • Jean-Francois Paradis, Salesforce
  • Patrick Soquet, Moddable
  • Kris Kowal, Agoric

Synopsis

Provide a mechanism to generate ECMAScript code that provides compartmentalized host behavior from other ECMAScript code.

This is extracting some desired behavior from the existing SES Proposal for generalized use.

Motivation

ECMAScript has many environments in which it runs, and many behaviors are host driven. For a variety of use cases, the ability to alter the standard host behavior is desirable.

Some of these environments are build time instead of run time, such as bundling tool chains that override how things like import behavior. These are explicitly not creating an isolation of the object address space for JS and share the global with things using them.

Various efforts like JSDom, SES, and even testing frameworks seek to override host behaviors within Realms. In TC39 we sometimes refer to this as the ability to virtualize behavior.

Currently there is no standard way of virtualizing all behavior, nor of compartmentalizing behaviors to specific source text. By introducing a means to virtualize behaviors for source texts a variety of workflows are able to be created without relying on host specific APIs. Existing method often replace host driven APIs such as Date in various ways such as detached <iframes>, using CSP, Node.js's vm.createContext, XS's Compartments implementation, etc.

Historically, the SES proposal sought to achieve this behavior through isolation as a security boundary, but the utility of changing these behaviors is outside of purely security concerns. A large number of potential use cases lie purely in the ability to virtualize some host behavior.

Currently, things like changing the effective time zone, locale, or limiting the ability to eval code withing a source text is not possible in JS.TC39 in the past has seen desires to override specific host behaviors such as Zones in specific situations. This proposal seeks to provide a way to coordinate host behavior of such APIs in a generalized manner.

It seeks to do so in the following solution space:

  • without requiring separating the address space
  • without requiring an asynchronous messaging system
  • keeping shared intrinsics of source texts
  • does not allow escaping existing host security mechanisms

It leaves concerns about those to other proposals such as Realms or even host APIs.

Rationale

There are several ways in which these behaviors could be overriden.

  • They could only be allowed to be changed in a manner that is only virtualized to a newly allocated global scope.
  • They could be done via API virtualization at the global scope level.
  • They could be changed on a source text level to allow shared references without identity discontinuity.

The status quo is API virtualization at the global scope level, but not all behavior is virtualizable doing so; notably, direct eval, import.meta, and import.

There are ways to virtualize most other APIS

Sketch

This is a rough sketch of potential APIs.

// Shared space by Realm
type FullSpecifier = string;
type ModuleNamespace = object;
// Used to create a Reusable Instance factory for Module Records
// exotic
interface StaticModuleRecord {
  // intend to add reflection of import/export bindings

  // needs to allow duplicates
  staticImports(): {specifier: string, exportNames}[];
}
interface SourceTextStaticModuleRecord extends StaticModuleRecord {
  // no coerce
  constructor(source: string);
}
type CompartmentConstructorOptions = {
  // JF has a better way for:
  //   randomHook(): number; // Use for Math.random()
  //   nowHook(): number; // Use for both Date.now() and new Date(),
  // Supplied during Module instance creation instead of option
  //   hasSourceTextAvailableHook(scriptOrModule): bool; // Used for censorship
  resolveHook(name: string, referrer: FullSpecifier): FullSpecifier

  // timing
  // importHook delegates to importNowHook FIRST,
  // they share a memo cache
  // order to ensures importNow never allows async value of import
  // to be accessed prior to any attached promise
  importHook(fullSpec: FullSpecifier): Promise<StaticModuleRecord>;
  importNowHook(fullSpec: FullSpecifier): StaticModuleRecord?;

  // copy own props after return
  importMetaHook(fullSpec: FullSpecifier): object

  // e.g.: 'fr-FR' - Affects appropriate ECMA-402 APIs within Compartment
  localeHook(): string;
  // This is important to be able to override for deterministic testing and such
  localTZAHook(): string;

  // determines if the fn is acting as an "eval" function
  isDirectEvalHook(evalFunctionRef: any): boolean;

  // prep for trusted types non-string
  canCompileHook(source: any, {
    evaluator: functionRef, // can be a value from isDirectEvalHook
    isDirect?: boolean
  }): boolean; // need to allow mimicing CSP including nonces
};
// Exposed on global object
// new Constructor per Compartment
//
// CreateRealm needs to be refactored to take params
//  - intrinsics: an intrinsics record from
//                6.1.7.4 Well-Known Intrinsic Objects
interface Compartment {
  constructor(
    // extra bindings added to the global
    endowments?: {
      [globalPropertyName: string]: any
    },
    // need to figure out module attributes as it progresses
    // maps child specifier to parent specifier
    moduleMap?: {[key: FullSpecifier]: FullSpecifier | ModuleNamespace},
    // including hooks like isDirectEvalHook
    options?: CompartmentConstructorOptions
  ): Compartment // an exotic compartment object

  // access this compartment's global object, getter
  globalThis: object;

  // do an eval in this compartment
  // default is strict indirect eval
  evaluate(
    // trusted types prep means use of `any`
    src: any,
    // FUTURE:
    //   for other eval goals like Module, need to discuss import()/eval() to
    //   get other Goals vs an option
    // options?: object
  ): any;

  // Return signature differs to allow avoiding then() exports
  // Used to ensure ability to be compatible with static import
  async import(specifier: string): Promise<{namespace: ModuleNamespace}>;
  // Desired by TC53
  importNow(specifier: string): ModuleNamespace;
  // Necessary to thread a module exports namespace from this compartment into
  // the `moduleMap` Compartment constructor argument, without importing (and
  // consequently executing) the module.
  module(specifier: string): ModuleNamespace;
}

Design rationales

Boxed module namespace returned by compartment import

An exported value named then can be statically imported, but dynamic import confuses the module namespace for a thenable object. The resolution of the promise returned by dynamic import, in this case, is the eventual resolution of the thenable module. This is unlikely to be an intended effect.

Consider thenable.js:

export function then(resolve) {
  resolve(42);
}

This might be dynamically imported by a neighboring module.

import('./thenable.js').then((x) => {
  // x will be 42 in this case, not a module namespace object with a then
  // function.
})

This is the behavior of a dynamic import today, despite it being surprising.

In this proposal, the Compartment.import function differs from the behavior of dynamic import by returning the namespace in a box.

compartment.import('./thenable.js').then(({namespace: x}) => {
  // x will be a module namespace object with a then function.
})