Skip to content
Draft
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
12 changes: 5 additions & 7 deletions isolation/ISOLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,9 @@ Which parts of the UI end up inside the boundary is a choice of the frame config
│ - unsigned in → signed out │
└─────────────────────────────────┘

* unknown: prompt — documents not in the host repo are not auto-allowlisted;
the user is prompted via window.confirm — see the Allowlist section.
* unknown: prompt — a document not in the host repo is auto-allowlisted only if
every change is authored by the iframe's own author id (it created it);
otherwise the user is prompted via window.confirm — see the Allowlist section.
```

> **Not yet implemented (future / Keyhive):** the "Keyhive Isolation Identity" box, the "Signs 'signable' commits with isolation identity" line, and the sync-channel "'signed or signable' filter" / "unsigned in → signed out" notes describe the Keyhive-enabled design. Today the intermediary holds no isolation identity and signs nothing, and edits flow through unsigned. See [Security without Keyhive](#security-without-keyhive).
Expand Down Expand Up @@ -216,7 +217,7 @@ The allowlist is seeded from the boot spec's `rootUrls`. It is expanded through

1. **Transitive discovery.** Each root document's content is scanned for embedded automerge URLs (recursively walking objects, arrays, and strings). All discovered URLs are added to the allowlist (unless denylisted). This reflects the assumption that if the user opened a document, its referenced children are authorized for the tool rendering it.

2. **User approval.** When the iframe requests a document that is not on the allowlist, the user is prompted via `window.confirm()` and can approve access explicitly. If the document is one the host repo already knows about, the allowlist is first refreshed (re-scanning all root documents for new URLs, e.g. a reference the user just typed) and the prompt is skipped if it now matches. Documents the host has never seen (newly created by the iframe, added by a collaborator, or embedded in the tool) skip that refresh — a root re-scan cannot surface a document the host has never seen — and prompt directly. Unknown documents are **not** auto-allowlisted; this prevents a tool from silently gaining access to any URL it constructs, at the cost of prompting for documents the iframe itself just created. Once the Author ID API is available, documents created by the iframe's own author ID will be auto-allowlisted while other unknown documents continue to prompt. _(See "Waiting on automerge/keyhive teams" below.)_
2. **Iframe-authored auto-allowlist + user approval.** When the iframe requests a document that is not on the allowlist: if the document is one the host repo already knows about, the allowlist is first refreshed (re-scanning all root documents for new URLs, e.g. a reference the user just typed) and access is granted if it now matches. Otherwise the gate checks **authorship** — using the Author ID API, the iframe's `Repo` is configured with a per-isolation-context author id (generated host-side, passed into the iframe at boot), so changes the iframe's tools make are attributed to it. If **every** change in the requested document was authored by that id, the document is **auto-allowlisted with no prompt** — it is one the iframe just created. Any other unknown document (never seen by the host, or with any change by a different/absent author) **prompts the user** via `window.confirm()`. This preserves the safe default — a tool cannot silently gain access to a URL it constructs or to a foreign document — while removing the spurious prompt for a tool opening the document it just made. The all-changes check is conservative on purpose: a doc with even one foreign-authored (or unauthored) change still prompts, so a tool can't smuggle access by appending an own-authored change to someone else's document. (Reading the author materializes the doc in the trusted host repo only; the doc becomes iframe-syncable only after it is added to the allowlist, so materializing to check authorship does not itself grant access.)

### Keyhive integration (future)

Expand Down Expand Up @@ -330,10 +331,7 @@ The modal renders a `<patchwork-view>` in the host DOM (outside the isolation bo

These are API changes being developed by the automerge and keyhive teams. The isolation architecture depends on them but cannot implement them until they ship.

- **Author ID API.** Configure an author ID on the iframe's Repo so that edits made by tools are correctly attributed. The iframe repo will be configured with the isolation identity's author ID. (Not yet available on main.) Once available, improve the unknown-document handling in `onAccessRequest`:
- When the intermediary encounters an unknown document whose author matches the iframe's assigned author ID, auto-allowlist it (the iframe created it) instead of prompting.
- For unknown documents with a different author, keep prompting the user.
- Today, _all_ unknown documents prompt the user (the blanket auto-allowlist workaround has been removed); this change would restore silent access for the iframe's own creations only.
- **~~Author ID API~~ — IMPLEMENTED.** The iframe's `Repo` is now configured with a per-isolation-context author id (generated host-side, passed in at boot), and `onAccessRequest` auto-allowlists an unknown document iff every change is by that author id (otherwise prompts). See the "Iframe-authored auto-allowlist" item under [Allowlist](#allowlist). (Requires the automerge authors release — `automerge-repo ≥ 2.7.0-authors`, `automerge ≥ 3.4.0-rev-frag-hex`; `RepoConfig.authorId` + `ChangeMetadata.author`.)
- **"Signed or signable" bridge config.** Configure the bridge connection (NetworkAdapter or similar) between the intermediary repo and the iframe repo to only accept signed or signable commits from the iframe direction. Signable commits are signed with the isolation identity; mis-attributed commits are dropped. (Not yet available.)

### Tracked separately
Expand Down
23 changes: 23 additions & 0 deletions isolation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ props tweak (collapse toggle, tool reorder) updates in place.
versions must match what the host serves (i.e. the other base modules'
versions). Keep `package.json` in step with `threepane/package.json`.

**Requires the automerge authors release** (`@automerge/automerge-repo ≥
2.7.0-authors`, `@automerge/automerge ≥ 3.4.0-rev-frag-hex`) for the author-id
feature below — `RepoConfig.authorId` + `ChangeMetadata.author`. The host import
map (patchwork-next catalog) must serve these too.

## Iframe-authored documents auto-allowlist

The iframe's `Repo` is configured with a per-isolation-context **author id**
(generated host-side in `bootIsolation`, passed into the iframe at boot, used as
both `peerId` and the repo's `authorId`). So every change the iframe's tools make
is attributed to that id.

The access gate (`handleAccessRequest`) uses this to skip the permission prompt
for a document the tool **just created**: when the iframe requests an
unknown-but-not-denylisted document, the gate reads its change metadata and
**auto-allowlists it iff *every* change is authored by the iframe's own id**.
Any other unknown document (or one with a foreign/absent-author change) still
prompts via `window.confirm()`. The all-changes check is deliberately
conservative — one foreign change and it prompts — so a tool can't gain access to
someone else's document by appending an own-authored change. Reading the author
materializes the doc only in the trusted host repo; a doc becomes iframe-syncable
only once added to the allowlist, so the check itself grants nothing.

## Registration

Like every patchwork module, this is published/registered independently (not a
Expand Down
5 changes: 3 additions & 2 deletions isolation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
"author": "Ink & Switch",
"license": "MIT",
"dependencies": {
"@automerge/automerge-repo": "^2.6.0-subduction.26",
"@automerge/automerge-repo-network-messagechannel": "^2.6.0-subduction.26",
"@automerge/automerge": "^3.4.0-rev-frag-hex.3",
"@automerge/automerge-repo": "^2.7.0-authors.1",
"@automerge/automerge-repo-network-messagechannel": "^2.7.0-authors.1",
"@inkandswitch/patchwork-elements": "1.0.0",
"@inkandswitch/patchwork-filesystem": "^0.0.8",
"@inkandswitch/patchwork-plugins": "^0.0.11",
Expand Down
23 changes: 22 additions & 1 deletion isolation/src/boot/host/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ export function bootIsolation(host: HTMLElement): IsolationHandle {
const rootComponentId = readRootComponentId(host);
const rootUrls = readAllowlistUrls(host);

// The isolation context's identity, generated host-side so the access gate can
// compare against the same value the iframe repo authors with (no round-trip).
// - `authorId`: a hex id (UUID prefix — all hex chars) used as the iframe
// repo's Automerge author, so changes it makes are attributed to it. The
// gate auto-allowlists an unknown doc iff *every* change is by this author.
// - `peerId`: the same id, human-readable, for the iframe repo's peer id +
// logging.
const authorId = crypto.randomUUID().slice(0, 8);
const isolationPeerId = `isolation-${authorId}`;

// State wired up during the boot, torn down together. All start empty, so
// teardown() before/during boot is a safe no-op over them.
const cleanups: Array<() => void> = [];
Expand All @@ -101,7 +111,10 @@ export function bootIsolation(host: HTMLElement): IsolationHandle {
let iframe: HTMLIFrameElement | null = null;

async function run() {
log(`init root "${rootComponentId}" with ${rootUrls.length} root URLs`);
log(
`init root "${rootComponentId}" with ${rootUrls.length} root URLs, ` +
`authorId=${authorId}`
);

const repo = getRepo(host);
if (!repo) return;
Expand Down Expand Up @@ -134,6 +147,9 @@ export function bootIsolation(host: HTMLElement): IsolationHandle {
allowlist,
hostRepo: repo,
denylist,
// The iframe repo authors its changes with this id (see the boot message);
// the intermediary auto-allowlists docs solely authored by it.
iframeAuthorId: authorId,
onAccessRequest: (documentId) =>
handleAccessRequest(repo, rootUrls, allowlist, denylist, documentId),
});
Expand Down Expand Up @@ -226,6 +242,11 @@ export function bootIsolation(host: HTMLElement): IsolationHandle {
// change between boot start and this async send is reflected in the
// initial boot rather than lost, since a pre-port push no-ops.
rootComponentData: readRootComponentData(host),
// The iframe repo's identity, generated host-side (see bootIsolation).
// `authorId` attributes the iframe's changes so the access gate can
// recognize docs it created; `peerId` is the readable form.
authorId,
peerId: isolationPeerId,
registryEntries,
esmsSource: assets.esmsSource,
hostStyles: assets.hostStyles,
Expand Down
13 changes: 12 additions & 1 deletion isolation/src/boot/iframe/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ interface InitMessage {
syncPort: MessagePort;
data: {
rootComponentId: string;
/** The iframe repo's Automerge author id (hex), generated host-side. */
authorId: string;
/** The iframe repo's peer id (readable form of the author id), host-side. */
peerId: string;
/**
* Opaque JSON data for the root component, relayed verbatim from the host
* (the boundary never parses it). Materialized as-is into the root's inert
Expand Down Expand Up @@ -107,6 +111,9 @@ export async function boot(deps: BootDeps) {
// until modules load, but we need logging during bootstrap.
// Respects the same localStorage("debug") namespace convention.
const NAMESPACE = "patchwork:elements:isolation:iframe";
// A local id used only to tag early console output (before the boot message
// arrives). The repo's real identity — peerId + authorId — comes from the boot
// message (host-generated), so the access gate can compare against it.
const peerId = "isolation-" + crypto.randomUUID().slice(0, 8);
let debugEnabled = false;
const log = (...args: unknown[]) => {
Expand Down Expand Up @@ -273,8 +280,12 @@ export async function boot(deps: BootDeps) {
const syncAdapter = new messagechannel.MessageChannelNetworkAdapter(
init.syncPort
);
// Identity is generated host-side (so the access gate compares against the
// same value): `authorId` attributes this repo's changes as the isolation
// context, letting the host auto-allowlist docs the iframe created.
const repo = new automergeRepo.Repo({
peerId: peerId,
peerId: d.peerId as any,
authorId: d.authorId,
network: [syncAdapter],
});
(window as any).repo = repo;
Expand Down
102 changes: 43 additions & 59 deletions isolation/src/bridges/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { log } from "../log.js";
/**
* Scan a single document's content for automerge URLs and add any new ones to
* the allowlist (unless they are denylisted or turn out to be sensitive — see
* {@link checkAndDenylistIfSensitive}).
* {@link denylistIfSensitive}).
*
* This is a one-shot scan, not a live subscription: it reads the document's
* current contents once. Callers re-invoke it (via the wrappers below) when
Expand Down Expand Up @@ -114,64 +114,44 @@ export async function buildAllowlist(
log(`allowlisted root ${url}`);
}

await populateAllowlistFromRoots(
repo,
rootUrls,
allowlist,
denylist,
isStale
);
await refreshAllowlistFromRoots(repo, rootUrls, allowlist, denylist, isStale);
return allowlist;
}

/**
* Scan multiple root documents into the allowlist, adding everything they
* transitively reference. Stops early if `isStale` flips (a newer init epoch
* started). Used by `buildAllowlist` for the initial boot-time seed.
* Scan all root documents into the allowlist, adding everything they
* transitively reference. Two uses:
* - boot-time seed (via `buildAllowlist`), passing `isStale` so the scan stops
* early if a newer init epoch started;
* - lazy refresh (e.g. when an access request arrives), called with no
* `isStale` to pick up references the user just added.
*
* When `isStale` is omitted the early-out is simply skipped.
*/
async function populateAllowlistFromRoots(
async function refreshAllowlistFromRoots(
repo: Repo,
rootUrls: AutomergeUrl[],
allowlist: SyncAllowlist,
denylist: SyncDenylist | undefined,
isStale: () => boolean
isStale?: () => boolean
): Promise<void> {
for (const url of rootUrls) {
await scanDocIntoAllowlist(repo, url, allowlist, denylist, isStale);
if (isStale()) return;
}
}

/**
* Re-scan all root documents and add any newly-referenced automerge URLs to
* the allowlist. Called lazily (e.g. when an access request arrives) rather
* than on every change, to catch references the user just added.
*/
async function refreshAllowlistFromRoots(
repo: Repo,
rootUrls: AutomergeUrl[],
allowlist: SyncAllowlist,
denylist: SyncDenylist | undefined
): Promise<void> {
for (const url of rootUrls) {
await scanDocIntoAllowlist(repo, url, allowlist, denylist);
if (isStale?.()) return;
}
}

/**
* Decide whether the iframe may access a document that isn't yet on the
* allowlist — the intermediary repo's `onAccessRequest` gate.
* Prompt the user to grant the iframe access to a document that isn't on the
* allowlist and wasn't auto-allowlisted (i.e. not solely authored by the iframe;
* the author check lives in the intermediary's `resolveAccess` /
* `isAuthoredSolelyByIframe` — see repo-bridge). Reached as the fallback for docs
* the iframe references but did not itself create.
*
* Unknown documents are NOT auto-allowlisted; the user is prompted. This is a
* safe default: it stops a tool from silently gaining access to any URL it
* constructs. The cost is that documents the iframe itself just created also
* prompt.
*
* TODO: once the Author ID API is available, auto-allowlist unknown documents
* whose author matches the iframe's assigned author ID (the iframe created
* them) and continue to prompt for all others.
*
* Returns true (and allowlists the doc) if access is granted.
* If the document is one the host repo already knows about, the allowlist is
* first refreshed (re-scanning roots for newly-added references) and the prompt
* is skipped if it now matches. Returns true (and allowlists) if access is
* granted.
*/
export async function handleAccessRequest(
repo: Repo,
Expand All @@ -180,28 +160,32 @@ export async function handleAccessRequest(
denylist: SyncDenylist,
documentId: DocumentId
): Promise<boolean> {
log(`handling access request for ${documentId}`);
let approved = false;
if (repo.handles[documentId]) {
// Known to the host but not yet allowlisted — the URL may have been added
// since the initial scan (e.g. the user typed a new reference), so re-scan
// roots before asking. (Skipped for unknown docs: a root re-scan can't
// surface a doc the host has never seen, so it would be wasted work.)
// roots before asking.
await refreshAllowlistFromRoots(repo, rootUrls, allowlist, denylist);
if (allowlist.has(documentId)) return true;

approved = window.confirm(
`A tool wants to access a document on your system:\n\n` +
`Document ID: ${documentId}\n\n` +
`Allow access?`
);
} else {
approved = window.confirm(
`A tool wants to access a remote document:\n\n` +
`Document ID: ${documentId}\n\n` +
`This document was not created by the tool and is not on your local system. Allow access?`
);
}

// TODO: remove temp approval
// const approved = window.confirm(
// `A tool wants to access a document:\n\n` +
// `Document ID: ${documentId}\n\n` +
// `This may be a document the tool just created, or one it is ` +
// `trying to open. Allow access?`
// );
// if (approved) {
// allowlist.addDocumentId(documentId);
// }
// return approved;
allowlist.addDocumentId(documentId);
return true;
if (approved) {
allowlist.addDocumentId(documentId);
}
return approved;
}

/**
Expand Down Expand Up @@ -365,7 +349,7 @@ async function denylistModuleSettings(
* discovered later (e.g. referenced deep in user content) are caught lazily by
* `denylistIfSensitive`, which shares the same recognition logic.
*/
export async function populateDenylist(
async function populateDenylist(
repo: Repo,
denylist: SyncDenylist
): Promise<void> {
Expand Down Expand Up @@ -427,7 +411,7 @@ function sameDoc(a: AutomergeUrl, b: AutomergeUrl): boolean {
* deliberately do NOT fingerprint folders by shape, which would wrongly block
* the user's own content folders.
*/
export async function denylistIfSensitive(
async function denylistIfSensitive(
repo: Repo,
url: AutomergeUrl,
denylist: SyncDenylist
Expand Down
Loading