Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs/design/js-superpower/03-module-imports-via-egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

Remote module imports are a package policy, not a worker capability.
`REMOTE_MODULE_IMPORTS_ENABLED` is generated with the channel configuration.
Supported Preview targets enable it. Store, web, and Firefox packages disable it.
Chrome and Firefox Preview packages enable it. Store and web packages disable it.

## Supported imports

Notebook and Script entry code can use literal static imports and re-exports.
Relative files, `peerd:std`, `peerd:wasi`, and `peerd:toolbox/<name>` resolve
through the host where the browser loader supports them. Chrome Preview also
accepts literal static HTTP and HTTPS imports.
through the host. Chrome and Firefox Preview also accept literal static HTTP
and HTTPS imports.

Dynamic imports, `peerd.self.import`, computed specifiers, import attributes,
other URL schemes, and extension-absolute paths are refused with a stable
Expand Down Expand Up @@ -52,10 +52,12 @@ provider, browser, site, and dweb clients. Host relays refuse forged requests.
A remote module cannot import a local toolbox module. Returned values, logs,
and errors cross the untrusted-content fence before reaching the model.

Firefox refuses remote imports before fetching because its worker loader cannot
run the generated child module URLs. Native Firefox loading for local and
remote static graphs is tracked separately. The broader policy for executing
code fetched as ordinary data is also tracked separately.
Firefox uses the same resolver and policy, then links the authorized graph into
one strict worker script. A disposable packaged compiler Worker performs the
link and is terminated on Stop or deadline. The code Worker runs inside a
sandboxed, opaque-origin host with no extension APIs, no string compilation,
and `connect-src 'none'`. Store still refuses remote imports before fetching.
Preview enables them under the same compute-only profile as Chrome.

## Authoritative sources

Expand All @@ -64,6 +66,8 @@ code fetched as ordinary data is also tracked separately.
syntax.
- `extension/peerd-engine/module-resolver.js` enforces policy and builds static
module graphs.
- `extension/peerd-engine/single-module-linker.js` emits the Firefox worker
entry without child module loads.
- `extension/engine-tabs/notebook-tab/notebook-tab.js` and
`extension/offscreen/job-runner.js` wire the browser hosts.
- `packaging/verify-store-artifact.ts`, the packaged page check, and the
Expand Down
2 changes: 1 addition & 1 deletion docs/security/RED-TEAM-RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ _Generated from the current checkout by the command above._
- Claim checked: Across all three sandbox kinds, confinement holds: the Notebook realm exposes only the audited fetch bridge (raw channels throw, native fetch unrecoverable, bridge un-unseatable) and no same-origin durable store; the Cache API and IndexedDB both throw, so the sealed extension-origin worker cannot reach the `peerd` database; a remote module restricts its whole run to compute only and all remote-controlled output is fenced; an App cannot break out of its iframe or impersonate the service worker to issue actor commands; and the WebVM HTTP bridge refuses non-http(s) schemes, scrubs CRLF header injection, drops any smuggled auth field, and confirms body-bearing verbs.
- Threat-model invariant: INV-6
- Defenses exercised: applyRealmSeal (raw-channel block + native deletion + bridge pin), resolveRelativePath (OPFS ".." collapse), buildWorkerSource + formatEvalResult (remote graph capability collapse + output fence), composeApp + stripMetaRefresh (App iframe breakout/navigation defense), isServiceWorkerSender (actor-command source pin), normalizeRequest + needsWebWriteConfirm (WebVM bridge scheme/CRLF/auth/confirm)
- Verified in the browser by: `extension/tests/unit/engine-tabs/notebook-tab/notebook-seal.test.js (real worker realm); extension/tests/unit/offscreen/job-runner.test.js (a2a run denied egress + delegation); tests/peerd-engine/module-resolver-toolbox.test.ts (remote-to-local toolbox refusal); tests/engine-tabs/notebook-tab/worker-caps-profile.test.ts (remote whole-run profile); tests/peerd-runtime/tools/remote-import-policy.test.ts (remote output fence); extension/tests/unit/red-team/sandbox-escape.test.js (in-browser red-team framing); scripts/cdp/states.mjs actor-command-sender-pin (live engine-tab forgery); scripts/cdp/states.mjs notebook-remote-restricted (live visible-Notebook host wall)`
- Verified in the browser by: `extension/tests/unit/engine-tabs/notebook-tab/notebook-seal.test.js (real worker realm); extension/tests/unit/offscreen/job-runner.test.js (a2a run denied egress + delegation); tests/peerd-engine/module-resolver-toolbox.test.ts (remote-to-local toolbox refusal); tests/engine-tabs/notebook-tab/worker-caps-profile.test.ts (remote whole-run profile); tests/peerd-runtime/tools/remote-import-policy.test.ts (remote output fence); tests/peerd-engine/single-module-linker.test.ts (seal-first graph with no child loads); extension/tests/unit/red-team/sandbox-escape.test.js (in-browser red-team framing); scripts/firefox/run-runtime-tests.mjs (opaque worker host, string-compilation refusal, cancellable compiler and fetch, local and remote graph parity); scripts/cdp/states.mjs actor-command-sender-pin (live engine-tab forgery); scripts/cdp/states.mjs notebook-remote-restricted (live visible-Notebook host wall)`

| Probe (adversary action) | Result | Evidence |
|--------------------------|--------|----------|
Expand Down
8 changes: 6 additions & 2 deletions docs/security/THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,12 @@ caps, and may carry an optional SHA-256 pin. Any remote module marks the whole
resolved graph as untrusted compute. Runtime network and file access, agents,
model calls, browser and site clients, and dweb are disabled for that run. A
remote module cannot import a local toolbox module. Generated worker shims and
independent host relay checks enforce the same profile. Returned values,
console output, and errors are fenced as untrusted.
independent host relay checks enforce the same profile. Firefox links the
authorized graph and realm seal in a disposable compiler Worker, then runs the
result through a sandboxed opaque-origin host with no extension APIs, string
compilation, or network. Stop terminates the compiler or code Worker and
cancels the host fetch operation. Returned values, console output, and errors
are fenced as untrusted.
Pins verify exact bytes and improve reproducibility. They do not grant trust or
authority. Dynamic imports are refused in every package. Remote modules run
inside a sealed worker. The store build strips
Expand Down
23 changes: 18 additions & 5 deletions extension/background/notebook-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ export const createJsClient = ({ registry, tracker }) => {
return queue.enqueue(`resolve:${opts.sessionId}`, () => resolveId(opts));
};

/** @param {string} notebookId @param {{ type: string, [k: string]: unknown }} message */
const callTab = async (notebookId, message) => {
/** @param {string} notebookId @param {{ type: string, [k: string]: unknown }} message @param {AbortSignal} [abortSignal] */
const callTab = async (notebookId, message, abortSignal) => {
// background: agent-driven Notebook tabs never steal focus (DESIGN-12,
// 2026-06-18). sandbox_create already dropped a "go there" card; an auto-create
// here opens quietly too. ensureTab early-returns for a live tab.
Expand All @@ -81,13 +81,24 @@ export const createJsClient = ({ registry, tracker }) => {
});
/** @type {JsTabReply} */
let response;
const onAbort = () => {
browser.tabs.sendMessage(tabId, {
type: 'js/abort', notebookId, runId: message.runId,
}).catch(() => {});
};
// why dispatch first: an already-aborted signal must not let js/abort race
// ahead of the exact js/eval it terminates in the receiving tab.
const messagePromise = browser.tabs.sendMessage(tabId, { ...message, notebookId });
if (abortSignal?.aborted) onAbort();
else abortSignal?.addEventListener('abort', onAbort, { once: true });
try {
response = /** @type {JsTabReply} */ (await Promise.race([
browser.tabs.sendMessage(tabId, { ...message, notebookId }),
messagePromise,
timeoutPromise,
]));
} finally {
clearTimeout(timeoutId);
abortSignal?.removeEventListener('abort', onAbort);
}
if (!response || response.ok !== true) {
const err = new Error(response?.error ?? 'js call returned no response');
Expand All @@ -104,14 +115,16 @@ export const createJsClient = ({ registry, tracker }) => {
return {
resolveId,

/** @param {string} code @param {{ sessionId?: string, notebookId?: string, timeoutMs?: number }} [opts] */
/** @param {string} code @param {{ sessionId?: string, notebookId?: string, timeoutMs?: number, signal?: AbortSignal }} [opts] */
eval: async (code, opts = {}) => {
const id = await resolveIdQueued(opts);
const runId = crypto.randomUUID();
const response = await callTab(id, {
type: 'js/eval',
runId,
code,
timeoutMs: opts.timeoutMs,
});
}, opts.signal);
return response.result;
},

Expand Down
38 changes: 37 additions & 1 deletion extension/background/routes/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ export const makeEngineRoutes = (deps) => {
listOffscreenContexts, scriptRuns, isOffscreenSender,
} = deps;

/** @type {Map<string, AbortController>} */
const notebookFetchControllers = new Map();
/** @param {unknown} abortToken @param {unknown} notebookId @param {any} sender */
const notebookFetchKey = (abortToken, notebookId, sender) => {
if (typeof abortToken !== 'string' || abortToken.length > 128
|| typeof notebookId !== 'string' || notebookId.length > 128) return null;
const notebookRoot = browser.runtime.getURL('engine-tabs/notebook-tab/');
const senderUrl = sender?.url ?? sender?.tab?.url;
if (!senderUrl?.startsWith(notebookRoot)) return null;
try {
if (new URL(senderUrl).hash.slice(1).split(/[?&]/)[0] !== notebookId) return null;
} catch { return null; }
return `${notebookId}:${abortToken}`;
};

return {
// VM-originated HTTP egress. The VM tab's HTTP-marker dispatcher
// calls this when it sees a wrapper script's request marker. webFetch
Expand All @@ -33,7 +48,7 @@ export const makeEngineRoutes = (deps) => {
// an IO-injected factory (vm-net/vm-http-fetch.js) so it's bun-testable — it
// layers the revalidating IDB GET cache + host-bound git-auth + body cap +
// chunked base64 on top of webFetch's denylist/SSRF/audit chokepoint.
'sw/web-fetch': async ({ url, method, headers, body, gitAuth, noCache, extract, runId, ownerSessionId, deadlineAt }, sender = undefined) => {
'sw/web-fetch': async ({ url, method, headers, body, gitAuth, noCache, extract, runId, ownerSessionId, deadlineAt, abortToken, notebookId }, sender = undefined) => {
if (typeof url !== 'string' || url.length === 0) {
return { ok: false, error: 'url-required' };
}
Expand All @@ -45,6 +60,7 @@ export const makeEngineRoutes = (deps) => {
let onAbort = null;
/** @type {ReturnType<typeof setTimeout> | null} */
let deadlineTimer = null;
const notebookKey = notebookFetchKey(abortToken, notebookId, sender);
const carriesRun = runId !== undefined || ownerSessionId !== undefined;
if (carriesRun) {
if (typeof runId !== 'string' || typeof ownerSessionId !== 'string'
Expand All @@ -64,6 +80,17 @@ export const makeEngineRoutes = (deps) => {
if (remaining <= 0) runController.abort();
else deadlineTimer = setTimeout(() => runController?.abort(), remaining);
}
} else if (notebookKey) {
if (notebookFetchControllers.has(notebookKey)) {
return { ok: false, error: 'duplicate_notebook_fetch_token' };
}
runController = new AbortController();
notebookFetchControllers.set(notebookKey, runController);
if (typeof deadlineAt === 'number' && Number.isFinite(deadlineAt)) {
const remaining = deadlineAt - Date.now();
if (remaining <= 0) runController.abort();
else deadlineTimer = setTimeout(() => runController?.abort(), remaining);
}
}
// GET callers (the VM HTTP marker fast path) pass only { url } and behave
// exactly as before; the rich VM path + the Notebook code-mode bridge pass
Expand All @@ -87,8 +114,17 @@ export const makeEngineRoutes = (deps) => {
} finally {
if (deadlineTimer) clearTimeout(deadlineTimer);
if (sourceSignal && onAbort) sourceSignal.removeEventListener('abort', onAbort);
if (notebookKey && notebookFetchControllers.get(notebookKey) === runController) {
notebookFetchControllers.delete(notebookKey);
}
}
},
'sw/web-fetch-abort': async ({ abortToken, notebookId }, sender = undefined) => {
const key = notebookFetchKey(abortToken, notebookId, sender);
const controller = key ? notebookFetchControllers.get(key) : null;
controller?.abort();
return { ok: true, aborted: controller != null };
},

// --- App metadata fetch -----------------------------------------------
// app-tab/index.html requests its name + entry filename here at load
Expand Down
12 changes: 8 additions & 4 deletions extension/engine-tabs/notebook-tab/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
nor any realm that somehow escaped the seal can open a connection-class
request to ANY host, ours included. Store and web builds refuse remote
URL imports without requesting the module source. Preview resolves permitted
URL imports through the audited SW relay and re-blobs them, so the native
loader never receives a third-party script URL.
URL imports through the audited SW relay. Chrome uses the resolved module
graph directly. Firefox links it into one strict script and runs it in the
opaque sandboxed worker host below, whose CSP also has connect-src 'none'.
The SW keeps its own (manifest) CSP with https:, so webFetch still works.
-->
<meta http-equiv="Content-Security-Policy" content="connect-src 'none'">
Expand All @@ -28,6 +29,8 @@
<link rel="stylesheet" href="styles.css">
</head>
<body>
<iframe id="worker-host" sandbox="allow-scripts" hidden
title="Notebook worker host"></iframe>
<div id="notebook-boot" class="boot-card">
<div class="boot-spinner" aria-hidden="true"></div>
<h2>Starting Notebook…</h2>
Expand Down Expand Up @@ -57,7 +60,8 @@ <h2>Starting Notebook…</h2>
</div>
</details>
<button class="export-btn" id="export-btn" title="Download this Notebook as a .peerd file">Export ⤓</button>
<button class="run-btn" id="run-btn" title="Runs notebook.js (Cmd-Enter / Ctrl-Enter)">Run notebook.js ▶</button>
<button class="run-btn" id="run-btn" aria-label="Run notebook.js"
title="Runs notebook.js (Cmd-Enter / Ctrl-Enter)">Run notebook.js ▶</button>
</header>

<!-- peerd-engine/editor mounts here: file tree + CodeMirror. -->
Expand All @@ -67,7 +71,7 @@ <h2>Starting Notebook…</h2>
<div id="pane-resizer" role="separator" aria-orientation="horizontal" tabindex="0"
title="Drag to resize (↑/↓ to nudge)" aria-label="Resize editor and output"></div>

<div id="output-pane">
<div id="output-pane" role="region" aria-label="Notebook output">
<div id="console-output"></div>
</div>
<p id="run-status" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></p>
Expand Down
24 changes: 24 additions & 0 deletions extension/engine-tabs/notebook-tab/linker-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// @ts-check
// Disposable Firefox Notebook compiler host.

import { linkSingleModuleWorkerDetailed } from '/peerd-engine/index.js';

self.addEventListener('message', async (event) => {
const message = event.data;
if (!message || message.type !== 'link') return;
try {
const linked = await linkSingleModuleWorkerDetailed(
String(message.source),
new Map(Array.isArray(message.cache) ? message.cache : []),
Array.isArray(message.packagedEntryUrls) ? message.packagedEntryUrls : [],
undefined, undefined, Number(message.entryBodyLine),
);
self.postMessage({ type: 'linked', ...linked });
} catch (error) {
self.postMessage({
type: 'link-failed',
error: /** @type {{ message?: string }} */ (error)?.message ?? String(error),
errorCode: /** @type {{ code?: string }} */ (error)?.code,
});
}
});
16 changes: 7 additions & 9 deletions extension/engine-tabs/notebook-tab/notebook-neutralizers.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@
// worker loader, dead in module workers, sealed anyway), and the
// nested Worker / SharedWorker constructors — a nested worker is a
// FRESH realm with un-sealed natives, so it must not exist at all.
// 3. The seal runs as the worker entry's FIRST static import (notebook-tab.js
// emits `import "<seal blob>"` ahead of the agent's imports). Module
// graphs evaluate depth-first in declaration order, so the seal
// executes before any agent module's top-level body — closing the
// old gap where statically-imported agent code ran pre-stub.
// 3. The seal is the worker graph's first edge. Chrome evaluates that import
// first. Firefox's single-entry linker emits the seal body first. Both
// execute it before any agent module body, closing the old pre-seal gap.
//
// What this still is NOT: the outermost fence. The host page's CSP
// (notebook-tab/index.html, connect-src 'none') backstops the seal in the
Expand All @@ -32,12 +30,12 @@
// realm itself, not deferred to the page CSP.
// Module loads are NOT an open channel. Store and web builds refuse remote
// URL imports without requesting the module source. Preview's HOST resolver
// fetches permitted module source through the audited data relay and hands the
// worker a same-realm blob, so the native loader still sees no third-party URL.
// fetches permitted module source through the audited data relay. The worker
// receives only host-resolved code, never a third-party network URL.
// import() itself is syntax, not a global, so there is nothing to seal here.
//
// One implementation, three callers: realm-seal.js (the worker entry's
// first static import — the production path), the bun unit tests (mock
// One implementation, three callers: realm-seal.js (the worker graph's
// first edge, linked into Firefox's single script), the bun unit tests (mock
// globals), and the in-browser tests (real worker realms). All import
// applyRealmSeal from here, so production and tests cannot drift.
/**
Expand Down
Loading