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
5 changes: 3 additions & 2 deletions docs/design/portable-identity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ The code is the specification:
- Fail-loud export, targeted custody RPC, authenticated same-did import,
serialized custody reads and changes, and a two-step keep/replace decision
when the local did differs or its stored material is unreadable.
- An exact-options backup Port, guarded transfer routes, and a static
single-receiver check keep backup passwords off broadcast messaging.
- A targeted Chrome MessageChannel, an exact-sender Firefox backup Port,
guarded transfer routes, and a static receiver check keep backup passwords
off broadcast messaging.
- Structural exclusion of raw identity and device-key secrets from the generic
encrypted-secrets section.

Expand Down
3 changes: 2 additions & 1 deletion docs/security/HARDENING-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ relay authority to a live run and its sender. Firefox and interruption paths
must fail closed when the required isolation host is unavailable.

Current source: `extension/peerd-runtime/actor/`,
`extension/background/offscreen-actor-client.js`, and
`extension/background/offscreen-actor-client.js`,
`extension/background/offscreen-actor-channel-client.js`, and
`extension/offscreen/`.

### Browser authority
Expand Down
8 changes: 4 additions & 4 deletions docs/security/RED-TEAM-RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@ _Generated from the current checkout by the command above._

- Adversary: malicious sandboxed code
- Asset: the host origin, the network, and other sandbox instances
- 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.
- 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 observe a targeted actor job; 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); 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)`
- 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), makeOffscreenActorChannelClient (exact-client channel transfer), 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); 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-channel-targeting (live sibling-observer probe); scripts/cdp/states.mjs notebook-remote-restricted (live visible-Notebook host wall)`

| Probe (adversary action) | Result | Evidence |
|--------------------------|--------|----------|
Expand All @@ -169,7 +169,7 @@ _Generated from the current checkout by the command above._
| break the model trust fence through remote return, console, or error text | blocked | host status stays outside a neutralized untrusted envelope |
| embed </script> in an inlined App worker to break out of the shim | blocked | worker source `<` escaped to \u003c, no executable breakout tag |
| meta-refresh the App frame to an attacker URL | blocked | meta http-equiv=refresh stripped from the app HTML |
| replay a broadcast actor command from a first-party engine tab | blocked | exact service-worker source accepted; same-extension tab provenance rejected |
| observe an actor job from a first-party engine tab | blocked | the targeted channel offer carries no job or authority; the job moves only over the transferred endpoint |
| WebVM requests file:// / chrome:// to read local resources | blocked | normalizeRequest throws RangeError on non-http(s)/peerd:// schemes |
| CRLF-inject a second header through a WebVM request | blocked | CR/LF scrubbed from the header value ("aInjected: 1") |
| smuggle an auth field on the WebVM wire to attach the git token | blocked | normalizeRequest drops the auth field, only host control ops set credentials |
Expand Down
13 changes: 11 additions & 2 deletions docs/security/THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ holds both untrusted input and dangerous capability. Enforcement lives in
turn before any target action.
- B2. An actor loop and the network or the key. Model and tool calls leave the
worker only through privileged, gated relays. The host adds live provider
functions only at the model-call boundary and re-checks every tool call.
functions only at the model-call boundary and re-checks every tool call. On
Chrome, the service worker transfers a standard MessageChannel endpoint to the
exact offscreen WindowClient. The job and relays never use extension-wide
runtime messaging. Firefox binds the same relays to its private in-process host.
- B3. The extension and the open web. All outbound bytes pass through
`peerd-egress/fetch/`: `safeFetch` (exact-origin provider allowlist, carries the
key) or `webFetch` (SSRF and private-network block plus denylist, keyless).
Expand All @@ -111,6 +114,11 @@ holds both untrusted input and dangerous capability. Enforcement lives in
(`manifests/`, generated `extension/manifest.json`).
- B7. The user and the agent. Side-effecting actions pass through a confirm gate.
The vault requires an explicit unlock. Skills and imports require a click.
Backup and restore plaintext uses an exact options-page transport. Chrome
transfers a MessageChannel to one WindowClient. Firefox accepts a private
background Port only from the exact options sender. The ordinary runtime
message dispatcher never carries passphrases or backup payloads
(`background/private-transfer-port.js`, `options/private-transfer-session.js`).

Out of the model entirely (see section 7): a compromised OS or browser, a
malicious separate extension, and physical device access.
Expand Down Expand Up @@ -272,7 +280,8 @@ relay because `makeRelayedCallModel` drops them. Every untrusted summary re-ente
orchestrator wrapped as data (`makeActorSummaryFence` and `wrapUntrusted`) with a
delimiter the content cannot forge (`neutralizeFence`).
Code: `peerd-runtime/actor/actor-worker-core.js`,
`background/offscreen-actor-client.js`, `background/direct-actor-host.js`,
`background/offscreen-actor-client.js`, `background/offscreen-actor-channel-client.js`,
`background/direct-actor-host.js`, `offscreen/actor-channel-host.js`,
`offscreen/actor-runner.js`, `offscreen/actor-worker-protocol.js`, and
`tools/prompt-wrap.js`. The browser custody test proves an actor cannot enter the
privileged turn driver. The installed-XPI Firefox smoke proves the packaged extension
Expand Down
33 changes: 33 additions & 0 deletions extension/background/actor-startup-retry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// @ts-check
// Pure retry shell for actor failures that prove no work began.

import { isKnownActorStartupFailure } from './offscreen-actor-channel-client.js';

const LEGACY_ACTOR_HOST_STARTUP_CODES = new Set([
'actor_host_unavailable',
'actor_host_not_ready',
'actor_host_keepalive_failed',
'actor_worker_spawn_failed',
'actor_worker_start_timeout',
'actor_worker_crashed',
'actor_worker_message_error',
'actor_worker_protocol_error',
]);

export const isActorHostStartupFailure = (/** @type {any} */ result) =>
isKnownActorStartupFailure(result)
|| (result?.started === false && LEGACY_ACTOR_HOST_STARTUP_CODES.has(result?.code));

/**
* @param {Object} deps
* @param {() => Promise<any>} deps.run
* @param {(result: any) => boolean} deps.isStartupFailure
* @param {AbortSignal} [deps.signal]
*/
export const runActorWithStartupRetry = async ({ run, isStartupFailure, signal }) => {
let result = await run();
if (!isStartupFailure(result)) return { result, exhausted: false };
if (signal?.aborted) return { result: { ...result, aborted: true }, exhausted: false };
result = await run();
return { result, exhausted: isStartupFailure(result) };
};
222 changes: 222 additions & 0 deletions extension/background/offscreen-actor-channel-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// @ts-check
// Targeted Chrome transport for isolated actor runs.
//
// why not runtime.connect: Chrome permits one Port to have multiple receivers.
// The service worker instead finds the exact offscreen WindowClient and
// transfers one standard MessageChannel endpoint directly to that document.

import { ACTOR_CHANNEL_OFFER, ACTOR_CHANNEL_PROTOCOL } from '../shared/actor-channel-protocol.js';

export class ActorChannelError extends Error {
/** @param {string} message @param {string} code @param {{ cause?: unknown }} [options] */
constructor(message, code, options = {}) {
super(message, options);
this.name = 'ActorChannelError';
this.code = code;
}
}

const abortedResult = () => ({
ok: false, started: false, phase: 'startup', code: 'actor_run_aborted',
error: 'actor run aborted', aborted: true, outcomeKnown: true,
});

/** A structured failure that is safe to retry because no actor work began. */
export const isKnownActorStartupFailure = (/** @type {any} */ value) => value?.started === false
&& value?.phase === 'startup' && value?.outcomeKnown === true;

/**
* Fail closed when the exact offscreen recipient is absent or ambiguous.
* @template {{ url?: string }} T
* @param {T[]} candidates
* @param {string} expectedUrl
* @returns {T | null}
*/
export const selectExactActorHostClient = (
candidates,
expectedUrl,
) => {
const exact = candidates.filter((client) => client.url === expectedUrl);
return exact.length === 1 ? exact[0] : null;
};

/**
* @param {Object} deps
* @param {() => Promise<void>} deps.ensureOffscreen
* @param {() => Promise<{ postMessage: (message: any, transfer: Transferable[]) => void } | null>} deps.findOffscreenClient
* @param {() => MessageChannel} [deps.createChannel]
* @param {() => string} [deps.newChannelId]
* @param {number} [deps.handshakeTimeoutMs]
* @param {number} [deps.abortTimeoutMs]
* @param {(job: any) => number} [deps.runTimeoutMsFor]
* @param {typeof setTimeout} [deps.setTimeoutFn]
* @param {typeof clearTimeout} [deps.clearTimeoutFn]
*/
export const makeOffscreenActorChannelClient = ({
ensureOffscreen,
findOffscreenClient,
createChannel = () => new MessageChannel(),
newChannelId = () => crypto.randomUUID(),
handshakeTimeoutMs = 10_000,
abortTimeoutMs = 5_000,
runTimeoutMsFor = (job) => Number.isFinite(job?.budgetMs) && job.budgetMs > 0
? job.budgetMs + abortTimeoutMs
: 30 * 60_000,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
}) => {
/**
* @param {any} job
* @param {{ signal?: AbortSignal, relay: (type: string, payload: any) => any|Promise<any> }} options
*/
const run = async (job, { signal, relay }) => {
if (signal?.aborted) return abortedResult();
try { await ensureOffscreen(); }
catch (cause) {
return {
ok: false, started: false, phase: 'startup', code: 'actor_host_unavailable',
error: `actor host unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
outcomeKnown: true,
};
}
if (signal?.aborted) return abortedResult();
const target = await findOffscreenClient().catch(() => null);
if (!target) return {
ok: false, started: false, phase: 'startup', code: 'actor_host_not_ready',
error: 'actor host is not available', outcomeKnown: true,
};

const channelId = newChannelId();
const { port1, port2 } = createChannel();
/** @type {Map<string, Promise<any>>} */
const relayReplies = new Map();
let state = /** @type {'offered'|'ready'|'opened'|'accepted'|'committed'|'settled'} */ ('offered');
let settle = (/** @type {any} */ _value) => {};
let handshakeTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
let abortTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
let runTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);

const result = new Promise((resolve) => { settle = resolve; });
const finish = (/** @type {any} */ value) => {
if (state === 'settled') return;
state = 'settled';
if (handshakeTimer) clearTimeoutFn(handshakeTimer);
if (abortTimer) clearTimeoutFn(abortTimer);
if (runTimer) clearTimeoutFn(runTimer);
signal?.removeEventListener('abort', onAbort);
try { port1.close(); } catch { /* already closed */ }
settle(value);
};
const armHandshakeTimeout = (/** @type {string} */ phase) => {
if (handshakeTimer) clearTimeoutFn(handshakeTimer);
handshakeTimer = setTimeoutFn(() => finish({
ok: false,
started: state === 'committed',
phase: state === 'committed' ? 'run' : 'startup',
code: state === 'committed' ? 'actor_channel_lost' : `actor_channel_${phase}_timeout`,
error: `actor channel ${phase} timed out`,
outcomeKnown: state !== 'committed',
}), handshakeTimeoutMs);
};
const post = (/** @type {Record<string, any>} */ message) => port1.postMessage({
protocol: ACTOR_CHANNEL_PROTOCOL, channelId, ...message,
});
const onAbort = () => {
if (state === 'settled') return;
if (state !== 'committed') {
try { post({ type: 'actor/abort' }); } catch { /* not started */ }
finish(abortedResult());
return;
}
try { post({ type: 'actor/abort' }); } catch { /* watchdog settles */ }
abortTimer = setTimeoutFn(() => finish({
ok: false, started: true, phase: 'run', code: 'actor_abort_unacknowledged',
error: 'actor host did not acknowledge cancellation', aborted: true,
outcomeKnown: false,
}), abortTimeoutMs);
};
signal?.addEventListener('abort', onAbort, { once: true });

port1.onmessage = (event) => {
const message = event.data;
if (!message || message.protocol !== ACTOR_CHANNEL_PROTOCOL
|| message.channelId !== channelId || state === 'settled') return;
if (message.type === 'channel/ready' && state === 'offered') {
state = 'ready';
if (signal?.aborted) { onAbort(); return; }
post({ type: 'actor/open', job });
state = 'opened';
armHandshakeTimeout('accept');
return;
}
if (message.type === 'actor/accepted' && state === 'opened') {
state = 'accepted';
if (signal?.aborted) { onAbort(); return; }
post({ type: 'actor/commit' });
state = 'committed';
if (handshakeTimer) { clearTimeoutFn(handshakeTimer); handshakeTimer = null; }
runTimer = setTimeoutFn(() => {
try { post({ type: 'actor/abort' }); } catch { /* host is gone */ }
finish({
ok: false, started: true, phase: 'run', code: 'actor_channel_run_timeout',
error: 'actor channel exceeded its run budget', outcomeKnown: false,
});
}, Math.max(1, runTimeoutMsFor(job)));
return;
}
if (message.type === 'actor/result' && state === 'committed') {
finish(message.result ?? {
ok: false, started: true, error: 'actor host returned no result', outcomeKnown: false,
});
return;
}
if (message.type !== 'actor/relay' || state !== 'committed'
|| typeof message.requestId !== 'string'
|| typeof message.relayType !== 'string') return;
// A duplicate request shares the first dispatch promise. Even though the
// MessageChannel is private, transport retries must never repeat a tool.
let pending = relayReplies.get(message.requestId);
if (!pending) {
pending = Promise.resolve(relay(message.relayType, message.payload ?? {}))
.catch((cause) => ({
ok: false, error: cause instanceof Error ? cause.message : String(cause),
}));
relayReplies.set(message.requestId, pending);
}
pending.then((reply) => {
if (state !== 'committed') return;
try { post({ type: 'actor/relay-response', requestId: message.requestId, result: reply }); }
catch { /* run timeout or restart owns settlement */ }
});
};
port1.onmessageerror = () => finish({
ok: false, started: state === 'committed',
phase: state === 'committed' ? 'run' : 'startup',
code: 'actor_channel_message_error', error: 'actor channel received an invalid message',
outcomeKnown: state !== 'committed',
});
port1.addEventListener('close', () => finish({
ok: false, started: state === 'committed',
phase: state === 'committed' ? 'run' : 'startup',
code: 'actor_channel_closed', error: 'actor channel closed before settlement',
outcomeKnown: state !== 'committed',
}), { once: true });
try {
target.postMessage({
type: ACTOR_CHANNEL_OFFER, protocol: ACTOR_CHANNEL_PROTOCOL, channelId,
}, [port2]);
port1.start();
armHandshakeTimeout('ready');
} catch (cause) {
finish({
ok: false, started: false, phase: 'startup', code: 'actor_channel_offer_failed',
error: `actor channel could not be offered: ${cause instanceof Error ? cause.message : String(cause)}`,
outcomeKnown: true,
});
}
if (signal?.aborted) onAbort();
return result;
};

return { run };
};
Loading