@botiverse/porthole 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { type CapabilityName, type Gate } from "../config/capabilities.ts";
2
- import type { EgressConfig, PortholeConfig } from "../config/schema.ts";
2
+ import type { PortholeConfig } from "../config/schema.ts";
3
3
  /**
4
4
  * The frame's policies, derived from the config: its CSP (in the shell's `<meta>` for opaque
5
5
  * frames, in headers for served ones), its `sandbox` and `allow` attributes, and the `frame-src`
@@ -7,7 +7,9 @@ import type { EgressConfig, PortholeConfig } from "../config/schema.ts";
7
7
  */
8
8
  /** A CSP directive: its name, then its sources. */
9
9
  export type CspDirective = readonly [name: string, ...sources: string[]];
10
- export type EgressKind = keyof EgressConfig;
10
+ export type EgressKind = "connect" | "script" | "style" | "img" | "font" | "media";
11
+ /** Whether `kind` loads from any https site (`EgressConfig.anyHttps`, P059): passive kinds only. */
12
+ export declare function anyHttps(config: PortholeConfig, kind: EgressKind): boolean;
11
13
  /** The capabilities a config declares, in vocabulary order. */
12
14
  export declare function declaredCapabilities(config: PortholeConfig): CapabilityName[];
13
15
  /** The gates a declared capability runs under: its own, else the vocabulary's defaults. */
@@ -1,4 +1,8 @@
1
1
  import { CAPABILITIES } from "../config/capabilities.js";
2
+ /** Whether `kind` loads from any https site (`EgressConfig.anyHttps`, P059): passive kinds only. */
3
+ export function anyHttps(config, kind) {
4
+ return (config.egress.anyHttps ?? []).some((each) => each === kind);
5
+ }
2
6
  /** The capabilities a config declares, in vocabulary order. */
3
7
  export function declaredCapabilities(config) {
4
8
  return Object.keys(CAPABILITIES).filter((name) => config.capabilities[name] !== undefined);
@@ -34,13 +38,15 @@ export function frameCspDirectives(config) {
34
38
  return [];
35
39
  }
36
40
  const origins = (kind) => egressOrigins(config, kind);
41
+ // Any https site, for passive kinds a host opened so (P059); never for what runs or talks.
42
+ const https = (kind) => (anyHttps(config, kind) ? ["https:"] : []);
37
43
  return [
38
44
  ["default-src", "'none'"],
39
45
  ["script-src", "'unsafe-inline'", "'unsafe-eval'", "blob:", ...origins("script")],
40
46
  ["style-src", "'unsafe-inline'", ...origins("style")],
41
- ["font-src", "data:", ...origins("font")],
42
- ["img-src", "data:", "blob:", ...origins("img")],
43
- ["media-src", "data:", "blob:", ...origins("media")],
47
+ ["font-src", "data:", ...https("font"), ...origins("font")],
48
+ ["img-src", "data:", "blob:", ...https("img"), ...origins("img")],
49
+ ["media-src", "data:", "blob:", ...https("media"), ...origins("media")],
44
50
  ["connect-src", ...list(origins("connect"))],
45
51
  ["worker-src", "blob:"],
46
52
  ["child-src", "blob:"],
@@ -58,7 +58,16 @@ export interface EgressConfig {
58
58
  readonly img?: readonly string[];
59
59
  readonly font?: readonly string[];
60
60
  readonly media?: readonly string[];
61
+ /**
62
+ * Passive content that may load from any https site (P059): images, audio and video, fonts, for
63
+ * pages that hotlink them. Never scripts, styles or data. It opens a channel out: a viewer's
64
+ * address and a visit signal reach any site, and data can leave in the URL.
65
+ */
66
+ readonly anyHttps?: readonly PassiveKind[];
61
67
  }
68
+ /** What `EgressConfig.anyHttps` may open: content that runs nothing (P059). */
69
+ export declare const PASSIVE_KINDS: readonly ["img", "media", "font"];
70
+ export type PassiveKind = (typeof PASSIVE_KINDS)[number];
62
71
  export interface ThemeConfig {
63
72
  /**
64
73
  * Every CSS variable the content may read, with its default per scheme. The shell injects
@@ -1,3 +1,5 @@
1
+ /** What `EgressConfig.anyHttps` may open: content that runs nothing (P059). */
2
+ export const PASSIVE_KINDS = ["img", "media", "font"];
1
3
  /**
2
4
  * Sandbox tokens a url-mode frame may add to its baseline, by the host's choice (P037): a
3
5
  * person's own running app (a dev server's preview) may need popups (an OAuth flow), modals and
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITY_NAMES, isCapabilityName } from "./capabilities.js";
2
- import { URL_PERMISSIONS, URL_SANDBOX_EXTRAS } from "./schema.js";
2
+ import { PASSIVE_KINDS, URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./schema.js";
3
3
  /**
4
4
  * Checks a config before anything is derived from it. Every string that ends up in a CSP, an
5
5
  * attribute or the shell is checked here, so a malformed origin can't inject a directive
@@ -17,6 +17,147 @@ export class ConfigError extends Error {
17
17
  /** The built-in manual topics; a host's own topics may not reuse these names. */
18
18
  export const BUILT_IN_TOPICS = ["widget", "chart", "interactive"];
19
19
  const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/u;
20
+ /**
21
+ * Suffixes under which anyone can register or publish a name (a public suffix): a wildcard over
22
+ * one (`https://*.co.uk`, `https://*.github.io`) opens every stranger's site. Single labels (`com`,
23
+ * `dev`) are refused by rule; these are the common multi-label ones, registries' and hosting
24
+ * platforms'. Not the whole Public Suffix List: a wildcard should name the host's own domain.
25
+ */
26
+ const PUBLIC_SUFFIXES = new Set([
27
+ // Registries' second levels.
28
+ ...["co", "org", "ac", "gov", "me", "net", "ltd", "plc", "sch", "nhs", "police"].map((each) => `${each}.uk`),
29
+ ...["com", "net", "org", "edu", "gov", "asn", "id"].map((each) => `${each}.au`),
30
+ ...["co", "org", "net", "ac", "govt", "geek", "school"].map((each) => `${each}.nz`),
31
+ ...["co", "ne", "or", "ac", "go", "ad", "ed", "gr", "lg"].map((each) => `${each}.jp`),
32
+ ...["co", "or", "ne", "ac", "go", "re"].map((each) => `${each}.kr`),
33
+ ...["com", "net", "org", "gov", "edu"].flatMap((each) => [
34
+ "br",
35
+ "cn",
36
+ "hk",
37
+ "tw",
38
+ "sg",
39
+ "my",
40
+ "mx",
41
+ "ar",
42
+ "tr",
43
+ "ua",
44
+ "pl",
45
+ "ph",
46
+ "pk",
47
+ "ng",
48
+ "eg",
49
+ "sa",
50
+ "vn",
51
+ "co",
52
+ ].map((country) => `${each}.${country}`)),
53
+ ...["co", "net", "org", "gen", "firm", "ind", "ac", "edu", "gov", "res"].map((each) => `${each}.in`),
54
+ ...["co", "org", "ac", "gov", "net", "web"].map((each) => `${each}.za`),
55
+ ...["co", "org", "ac", "gov", "net", "muni"].map((each) => `${each}.il`),
56
+ "co.id",
57
+ "or.id",
58
+ "ac.id",
59
+ "co.th",
60
+ "or.th",
61
+ "ac.th",
62
+ "co.ke",
63
+ "co.tz",
64
+ "co.ug",
65
+ "com.de",
66
+ "co.at",
67
+ "or.at",
68
+ "ac.at",
69
+ "co.it",
70
+ "com.es",
71
+ "nom.es",
72
+ "com.fr",
73
+ "asso.fr",
74
+ "eu.org",
75
+ "us.com",
76
+ "uk.com",
77
+ "eu.com",
78
+ "uk.net",
79
+ "gb.net",
80
+ "jpn.com",
81
+ "kr.com",
82
+ // Hosting platforms: anyone gets a name under these.
83
+ "github.io",
84
+ "githubusercontent.com",
85
+ "gitlab.io",
86
+ "bitbucket.io",
87
+ "pages.dev",
88
+ "workers.dev",
89
+ "r2.dev",
90
+ "vercel.app",
91
+ "now.sh",
92
+ "netlify.app",
93
+ "netlify.com",
94
+ "herokuapp.com",
95
+ "herokussl.com",
96
+ "appspot.com",
97
+ "web.app",
98
+ "firebaseapp.com",
99
+ "cloudfunctions.net",
100
+ "run.app",
101
+ "googleusercontent.com",
102
+ "blogspot.com",
103
+ "azurewebsites.net",
104
+ "azurestaticapps.net",
105
+ "azureedge.net",
106
+ "blob.core.windows.net",
107
+ "cloudapp.net",
108
+ "trafficmanager.net",
109
+ "cloudfront.net",
110
+ "amazonaws.com",
111
+ "s3.amazonaws.com",
112
+ "elasticbeanstalk.com",
113
+ "awsapprunner.com",
114
+ "amplifyapp.com",
115
+ "fly.dev",
116
+ "onrender.com",
117
+ "railway.app",
118
+ "up.railway.app",
119
+ "glitch.me",
120
+ "repl.co",
121
+ "replit.app",
122
+ "replit.dev",
123
+ "surge.sh",
124
+ "deno.dev",
125
+ "ngrok.io",
126
+ "ngrok-free.app",
127
+ "ngrok.app",
128
+ "loca.lt",
129
+ "trycloudflare.com",
130
+ "codesandbox.io",
131
+ "csb.app",
132
+ "stackblitz.io",
133
+ "webcontainer.io",
134
+ "neocities.org",
135
+ "wordpress.com",
136
+ "tumblr.com",
137
+ "wixsite.com",
138
+ "squarespace.com",
139
+ "myshopify.com",
140
+ "carrd.co",
141
+ "notion.site",
142
+ "framer.app",
143
+ "webflow.io",
144
+ "readthedocs.io",
145
+ "gitbook.io",
146
+ "sourceforge.net",
147
+ "digitaloceanspaces.com",
148
+ "ondigitalocean.app",
149
+ "b-cdn.net",
150
+ "jsdelivr.net",
151
+ ]);
152
+ /** Why a wildcard's base can't be wildcarded, or null: a public suffix, or a single label. */
153
+ function wildcardProblem(base) {
154
+ if (base === "localhost") {
155
+ return null;
156
+ }
157
+ return !base.includes(".") || PUBLIC_SUFFIXES.has(base)
158
+ ? `is a wildcard over a public suffix (*.${base}): anyone can serve a site under it; name a domain of your own (https://*.cdn.example.com)`
159
+ : null;
160
+ }
20
161
  const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
21
162
  /**
22
163
  * Whether `value` is a CSP host source Porthole accepts: a scheme, a host (optionally `*.` for
@@ -42,6 +183,12 @@ export function originSourceProblem(value) {
42
183
  if (host !== "[::1]" && !HOST.test(host)) {
43
184
  return `${JSON.stringify(value)} ${shape}`;
44
185
  }
186
+ if (host.startsWith("*.")) {
187
+ const problem = wildcardProblem(host.slice(2));
188
+ if (problem !== null) {
189
+ return `${JSON.stringify(value)} ${problem}`;
190
+ }
191
+ }
45
192
  if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
46
193
  return `${JSON.stringify(value)} uses ${scheme} off loopback: ${scheme} is allowed only for localhost, *.localhost, 127.0.0.1 and [::1] (loopback development); use ${scheme === "http" ? "https" : "wss"}`;
47
194
  }
@@ -147,10 +294,23 @@ export function validateConfig(input) {
147
294
  }
148
295
  const egress = object(config.egress, "config.egress");
149
296
  const kinds = ["connect", "script", "style", "img", "font", "media"];
150
- only(egress, kinds, "config.egress");
297
+ only(egress, [...kinds, "anyHttps"], "config.egress");
151
298
  for (const kind of kinds) {
152
299
  sources(egress[kind], `config.egress.${kind}`);
153
300
  }
301
+ if (egress.anyHttps !== undefined) {
302
+ if (!Array.isArray(egress.anyHttps)) {
303
+ fail("config.egress.anyHttps", `must be a list of ${PASSIVE_KINDS.join(", ")}`);
304
+ }
305
+ egress.anyHttps.forEach((kind, index) => {
306
+ if (!PASSIVE_KINDS.includes(kind)) {
307
+ fail(`config.egress.anyHttps[${index}]`, `${JSON.stringify(kind)}: only images, media and fonts may load from any https site (P059), never scripts, styles or data`);
308
+ }
309
+ });
310
+ if (config.mode === "url") {
311
+ fail("config.egress.anyHttps", "url mode has no egress control");
312
+ }
313
+ }
154
314
  const theme = object(config.theme, "config.theme");
155
315
  only(theme, ["variables", "event", "documents"], "config.theme");
156
316
  if (theme.documents !== undefined &&
@@ -156,7 +156,7 @@ export const BEHAVIOR_PROBES = [
156
156
  {
157
157
  id: "11.theme-flip",
158
158
  item: 11,
159
- title: "a theme flip re-themes the running content in place, and tells it once",
159
+ title: "a theme flip re-themes the running content in place, and tells it once; no event without one",
160
160
  async run(context) {
161
161
  const config = inline({ theme: { event: "app:theme" } });
162
162
  const light = resolveTheme(config.theme, "light");
@@ -169,6 +169,13 @@ export const BEHAVIOR_PROBES = [
169
169
  const frame = await display.frame();
170
170
  await expect(frame.locator("p")).toHaveText("themed");
171
171
  expect(await variable(frame, "--color-text-primary")).toBe(light["--color-text-primary"]);
172
+ const flips = () => read(frame, () => window.flips);
173
+ // Mounting, and a host telling it the scheme it already has, are no flip: no event (#3).
174
+ await settle(400);
175
+ expect(await flips(), "no theme event at mount").toEqual([]);
176
+ await display.setTheme("light");
177
+ await settle(400);
178
+ expect(await flips(), "no theme event for the scheme it has").toEqual([]);
172
179
  await display.setTheme("dark");
173
180
  await expect
174
181
  .poll(() => variable(frame, "--color-text-primary"))
@@ -418,6 +425,12 @@ export const BEHAVIOR_PROBES = [
418
425
  content: PROMPT_CONTENT,
419
426
  });
420
427
  await click(display, "#ask");
428
+ // A host that always has a confirm function (a default dialog) asks anyway: what this case
429
+ // proves can't happen there (P058). Its answer is no, so nothing is sent either way.
430
+ const asked = await display.answerNextConfirmation(false, { timeoutMs: 1_500 }).then(() => true, () => false);
431
+ if (asked) {
432
+ context.inapplicable("this host always has a confirm function (a default dialog), so a display without one can't be shown; it asked the person instead");
433
+ }
421
434
  expect(await bodyData(display, "result")).toBe("false");
422
435
  expect(await refusals(display)).toContain("sendPrompt:no-confirm");
423
436
  expect(await display.calls()).toEqual([]);
@@ -20,6 +20,11 @@ export interface ProbeContext {
20
20
  requestsTo(origin: string): string[];
21
21
  /** Records a measured engine fact in the report. */
22
22
  measured(text: string): void;
23
+ /**
24
+ * Ends the probe as inapplicable on this host (reported as skipped, with the reason): what it
25
+ * proves can't be observed here, through no fault of the host's isolation (P058).
26
+ */
27
+ inapplicable(reason: string): never;
23
28
  }
24
29
  export interface Probe {
25
30
  /** Stable id, `<item>.<channel>`: what `LeakSuiteOptions.skip` names. */
@@ -11,6 +11,17 @@ export function attack(code, markup = "") {
11
11
  (async () => {
12
12
  const r = {};
13
13
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ // Whether a policy blocks \`origin\` though the frame's own allows it: then it is the page's,
15
+ // which an opaque frame inherits (P058). \`attempt\` loads something from it once more.
16
+ const inheritedBlocks = async (origin, attempt) => {
17
+ let blocked = false;
18
+ const seen = (event) => { if (String(event.blockedURI).indexOf(origin) === 0) blocked = true; };
19
+ document.addEventListener("securitypolicyviolation", seen);
20
+ try { await attempt(); } catch (e) {}
21
+ await wait(300);
22
+ document.removeEventListener("securitypolicyviolation", seen);
23
+ return blocked;
24
+ };
14
25
  try {
15
26
  ${code}
16
27
  } catch (error) {
@@ -255,6 +255,11 @@ const sdkProbe = (preset) => ({
255
255
  },
256
256
  });
257
257
  export { attack } from "./kit.js";
258
+ /**
259
+ * Why a positive control can't pass on this host: an opaque (`srcdoc`) frame inherits the
260
+ * embedding page's whole policy, and the page's own closes what the config opens (P058).
261
+ */
262
+ const INHERITED = (directive, origin) => `the page's own CSP, which an opaque frame inherits, blocks ${origin} (${directive}): content in opaque frames here is as closed as the page, whatever its config opens. Domain mode's frames don't inherit it.`;
258
263
  export const PROBES = [
259
264
  // ── 0: the shell's own wiring, in a real frame ─────────────────────────────────────────────
260
265
  {
@@ -358,10 +363,14 @@ export const PROBES = [
358
363
  worker.onmessage = (event) => (event.data === "sent" ? resolve() : reject());
359
364
  worker.onerror = reject;
360
365
  }));
361
- r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");`),
366
+ r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");
367
+ r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => fetch(${js(allowed.origin)} + "/policy").catch(() => {}));`),
362
368
  });
363
369
  const verdict = await context.result(display, 20_000);
364
370
  await settle();
371
+ if (verdict.allowed !== "oracle" && verdict.inherited === true) {
372
+ context.inapplicable(INHERITED("connect-src", allowed.origin));
373
+ }
365
374
  expect(verdict.allowed, "the allowed origin (the positive control)").toBe("oracle");
366
375
  expect(allowed.requests().map((request) => request.path)).toContain("/allowed");
367
376
  expect(canary.requests(), "requests reached the canary").toEqual([]);
@@ -405,10 +414,14 @@ export const PROBES = [
405
414
  try { new Worker(${js(`${c}/worker.js`)}); } catch (e) {}
406
415
  const ok = document.getElementById("ok");
407
416
  r.allowed = await new Promise((resolve) => { if (ok.complete && ok.naturalWidth > 0) resolve("loaded"); ok.onload = () => resolve("loaded"); ok.onerror = () => resolve("blocked"); setTimeout(() => resolve("timeout"), 3000); });
408
- r.script = typeof globalThis.__portholeOracleScript;`, markup),
417
+ r.script = typeof globalThis.__portholeOracleScript;
418
+ r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => { new Image().src = ${js(`${allowed.origin}/policy.png`)}; });`, markup),
409
419
  });
410
420
  const verdict = await context.result(display, 20_000);
411
421
  await settle();
422
+ if (verdict.allowed !== "loaded" && verdict.inherited === true) {
423
+ context.inapplicable(INHERITED("img-src", allowed.origin));
424
+ }
412
425
  expect(verdict.allowed, "the allowed image (the positive control)").toBe("loaded");
413
426
  expect(canary.requests(), "resources loaded from the canary").toEqual([]);
414
427
  expect(context.requestsTo(canary.origin)).toEqual([]);
@@ -857,6 +870,10 @@ export function leakTests(host, options = {}) {
857
870
  },
858
871
  requestsTo: (origin) => requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
859
872
  measured: (text) => info.annotations.push({ type: "measured", description: text }),
873
+ inapplicable: (why) => {
874
+ info.skip(true, `inapplicable on this host: ${why}`);
875
+ throw new Error(`inapplicable on this host: ${why}`);
876
+ },
860
877
  });
861
878
  }
862
879
  finally {
@@ -250,27 +250,76 @@ export function servedTests(host, options = {}) {
250
250
  expect(shown.origin).not.toBe(location.origin);
251
251
  }
252
252
  });
253
- define("served.domain.egress", "the CSP in the document's headers closes egress", async ({ page }) => {
253
+ define("served.domain.egress", "the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it", async ({ page }) => {
254
254
  const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
255
255
  const frame = await display.frame();
256
- const fetched = await frame.evaluate(async (url) => {
256
+ const kinds = ["fetch", "img", "media", "script", "style", "frame"];
257
+ const urls = Object.fromEntries(await Promise.all(kinds.map(async (kind) => [kind, await host.canary(`egress-${kind}`)])));
258
+ // Each kind tried once; the policy that applied is read from the violations it raises.
259
+ const seen = await frame.evaluate(async (each) => {
260
+ const policies = [];
261
+ document.addEventListener("securitypolicyviolation", (event) => policies.push(event.originalPolicy));
262
+ let fetched = "sent";
257
263
  try {
258
- await fetch(url, { mode: "no-cors" });
259
- return "sent";
264
+ await fetch(each.fetch, { mode: "no-cors" });
260
265
  }
261
266
  catch {
262
- return "blocked";
267
+ fetched = "blocked";
263
268
  }
264
- }, await host.canary("egress-fetch"));
265
- await frame.evaluate((url) => {
266
269
  const image = new Image();
267
- image.src = url;
270
+ image.src = each.img;
268
271
  document.body.append(image);
269
- }, await host.canary("egress-img"));
272
+ const audio = document.createElement("audio");
273
+ audio.preload = "auto";
274
+ audio.src = each.media;
275
+ document.body.append(audio);
276
+ const script = document.createElement("script");
277
+ script.src = each.script;
278
+ document.body.append(script);
279
+ const link = document.createElement("link");
280
+ link.rel = "stylesheet";
281
+ link.href = each.style;
282
+ document.head.append(link);
283
+ const nested = document.createElement("iframe");
284
+ nested.src = each.frame;
285
+ document.body.append(nested);
286
+ await new Promise((resolve) => setTimeout(resolve, 800));
287
+ return { fetched, policy: policies.find((policy) => /connect-src/u.test(policy)) ?? "" };
288
+ }, urls);
270
289
  await page.waitForTimeout(SETTLE_MS);
271
- expect(fetched).toBe("blocked");
272
- expect(await host.canaryHits("egress-fetch")).toBe(0);
273
- expect(await host.canaryHits("egress-img")).toBe(0);
290
+ expect(seen.fetched).toBe("blocked");
291
+ expect(seen.policy, "a CSP applied to the document").not.toBe("");
292
+ const sources = new Map(seen.policy
293
+ .split(";")
294
+ .map((directive) => directive.trim().split(/\s+/u))
295
+ .map(([name = "", ...rest]) => [name, rest]));
296
+ // What runs or talks never opens to any site (P059), whatever the config.
297
+ for (const name of ["script-src", "style-src", "connect-src", "frame-src", "default-src"]) {
298
+ expect(sources.get(name) ?? [], `${name} opens no scheme`).not.toContain("https:");
299
+ }
300
+ for (const kind of ["fetch", "script", "style", "frame"]) {
301
+ expect(await host.canaryHits(`egress-${kind}`), kind).toBe(0);
302
+ }
303
+ // Images and media: closed, unless the config opened them to any https site (P059);
304
+ // then, the canary being an https site, an image reaches it, as the config means.
305
+ const https = (kind) => (sources.get(`${kind}-src`) ?? []).includes("https:") &&
306
+ new URL(urls[kind]).protocol === "https:";
307
+ const opened = ["img", "media"].filter(https);
308
+ if (opened.length > 0) {
309
+ info?.annotations.push({
310
+ type: "expected",
311
+ description: `${opened.join(" and ")} load from any https site under this config (anyHttps, P059): ${opened.includes("img") ? "the image is asserted to reach the outside site" : "measured, not asserted"}; script, style, connect and frames still can't`,
312
+ });
313
+ }
314
+ if (https("img")) {
315
+ expect(await host.canaryHits("egress-img"), "the image, as the config opens it").toBeGreaterThan(0);
316
+ }
317
+ else {
318
+ expect(await host.canaryHits("egress-img"), "img").toBe(0);
319
+ }
320
+ if (!https("media")) {
321
+ expect(await host.canaryHits("egress-media"), "media").toBe(0);
322
+ }
274
323
  });
275
324
  const resourceReady = (html) => ({
276
325
  jsonrpc: "2.0",
@@ -10,6 +10,8 @@ import { BUILT_IN_TOPICS } from "../config/validate.ts";
10
10
  export type BuiltInTopic = (typeof BUILT_IN_TOPICS)[number];
11
11
  /** A topic name: a built-in one, or one of the config's own (`guide.topics`). */
12
12
  export type GuideTopic = BuiltInTopic | (string & {});
13
+ /** What the manual calls `https:` (any https site), where a config opens passive kinds to it. */
14
+ export declare const ANY_HTTPS_SITE = "any https site";
13
15
  /** The manual for `topics`, in the order asked (all built-in topics by default). */
14
16
  export declare function generateGuide(config: PortholeConfig, topics?: readonly GuideTopic[]): string;
15
17
  /** The description of the host's show tool, for the agent's tool list. */
@@ -1,6 +1,6 @@
1
1
  import { MAX_COPY_CHARS, capabilitySpec, } from "../config/capabilities.js";
2
2
  import { BUILT_IN_TOPICS } from "../config/validate.js";
3
- import { declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
3
+ import { anyHttps, declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
4
4
  import { shellPlan } from "../shell/plan.js";
5
5
  import { themeDefault } from "../theme/variables.js";
6
6
  import { GUIDE_EXAMPLES } from "./examples.js";
@@ -36,11 +36,18 @@ const KIND_WORDS = {
36
36
  media: "audio and video",
37
37
  connect: "data (fetch, WebSocket)",
38
38
  };
39
+ /** What the manual calls `https:` (any https site), where a config opens passive kinds to it. */
40
+ export const ANY_HTTPS_SITE = "any https site";
41
+ /** The sources content may load `kind` from, as the manual names them: origins, any https site. */
42
+ const sourcesFor = (config, kind) => [
43
+ ...(anyHttps(config, kind) ? [ANY_HTTPS_SITE] : []),
44
+ ...egressOrigins(config, kind),
45
+ ];
39
46
  /** What content may load, in prose, from the same origins the CSP lists. */
40
47
  function network(config) {
41
48
  const kinds = ["script", "style", "font", "img", "media", "connect"];
42
49
  const open = kinds
43
- .map((kind) => [kind, egressOrigins(config, kind)])
50
+ .map((kind) => [kind, sourcesFor(config, kind)])
44
51
  .filter(([, origins]) => origins.length > 0);
45
52
  if (open.length === 0) {
46
53
  return "Network: none. Nothing loads from outside (no scripts, styles, fonts, images or data from any server) and any fetch fails, so write everything inline: SVG and CSS draw charts well.";
@@ -248,9 +255,17 @@ export function generateToolDescription(config) {
248
255
  const origins = [
249
256
  ...new Set(["script", "style", "font", "img", "media", "connect"].flatMap((kind) => egressOrigins(config, kind))),
250
257
  ];
251
- const net = origins.length === 0
258
+ const passive = ["img", "media", "font"]
259
+ .filter((kind) => anyHttps(config, kind))
260
+ .map((kind) => KIND_WORDS[kind]);
261
+ const fromAnywhere = passive.length === 0
262
+ ? ""
263
+ : `${passive.length === 1 ? passive[0] : `${passive.slice(0, -1).join(", ")} and ${passive.at(-1)}`} from ${ANY_HTTPS_SITE}`;
264
+ const net = origins.length === 0 && fromAnywhere === ""
252
265
  ? "with no network (nothing loads from outside)"
253
- : `loading only from ${origins.join(", ")}`;
266
+ : origins.length === 0
267
+ ? `loading only ${fromAnywhere}`
268
+ : `loading only from ${origins.join(", ")}${fromAnywhere === "" ? "" : `, and ${fromAnywhere}`}`;
254
269
  const globals = declaredCapabilities(config).flatMap((name) => {
255
270
  const spec = capabilitySpec(name);
256
271
  if (spec.global === undefined || !c.global(spec.global)) {
package/dist/index.d.ts CHANGED
@@ -4,13 +4,14 @@
4
4
  */
5
5
  export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
6
6
  export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, StorageConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
7
+ export { PASSIVE_KINDS, type PassiveKind } from "./config/schema.ts";
7
8
  export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
8
9
  export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type UrlPermission, type UrlSandboxExtra, } from "./config/schema.ts";
9
10
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
10
11
  export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
11
12
  export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
12
13
  export { compile, type CompiledConfig } from "./compile/compile.ts";
13
- export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, type CspDirective, type EgressKind, } from "./compile/policy.ts";
14
+ export { allEgressOrigins, anyHttps, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, type CspDirective, type EgressKind, } from "./compile/policy.ts";
14
15
  export { shellPlan, type ShellPlan } from "./shell/plan.ts";
15
16
  export type { ShellDocumentOptions } from "./shell/document.ts";
16
17
  export { generateGuide, generateToolDescription, type BuiltInTopic, type GuideTopic, } from "./guide/guide.ts";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  // The config and its vocabulary.
6
6
  export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
7
+ export { PASSIVE_KINDS } from "./config/schema.js";
7
8
  export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
8
9
  export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./config/schema.js";
9
10
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
@@ -12,7 +13,7 @@ export { FRAME_STATUS_METHOD } from "./isolation/types.js";
12
13
  export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
13
14
  // The compiler.
14
15
  export { compile } from "./compile/compile.js";
15
- export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, } from "./compile/policy.js";
16
+ export { allEgressOrigins, anyHttps, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, } from "./compile/policy.js";
16
17
  export { shellPlan } from "./shell/plan.js";
17
18
  export { generateGuide, generateToolDescription, } from "./guide/guide.js";
18
19
  export { GUIDE_EXAMPLES } from "./guide/examples.js";
@@ -60,6 +60,10 @@ const mergeEgress = (base, extra) => {
60
60
  merged[kind] = origins;
61
61
  }
62
62
  }
63
+ const passive = [...new Set([...(base.anyHttps ?? []), ...(extra?.anyHttps ?? [])])];
64
+ if (passive.length > 0) {
65
+ merged.anyHttps = passive;
66
+ }
63
67
  return merged;
64
68
  };
65
69
  /** The default variables, with the host's changes and additions, less those it dropped (null). */
@@ -332,15 +332,22 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
332
332
  attempt(() => display.annotations.focus(id), latest());
333
333
  }
334
334
  }, [annotates, focusToken]);
335
+ // A flip or a restyle after the display was made: its first scheme and values went with it.
336
+ const [mountScheme] = useState(scheme);
337
+ const flipped = useRef(false);
335
338
  useEffect(() => {
336
- handle.current?.setTheme(scheme);
337
- }, [scheme]);
339
+ if (flipped.current || scheme !== mountScheme) {
340
+ flipped.current = true;
341
+ handle.current?.setTheme(scheme);
342
+ }
343
+ }, [scheme, mountScheme]);
338
344
  const { themeKey } = frame;
345
+ const [mountThemeKey] = useState(themeKey);
339
346
  useEffect(() => {
340
- if (themeKey !== undefined) {
347
+ if (themeKey !== undefined && themeKey !== mountThemeKey) {
341
348
  handle.current?.refreshTheme();
342
349
  }
343
- }, [themeKey]);
350
+ }, [themeKey, mountThemeKey]);
344
351
  return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
345
352
  position: "relative",
346
353
  // A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
@@ -55,6 +55,10 @@ function mergeEgress(base, extra) {
55
55
  merged[kind] = origins;
56
56
  }
57
57
  }
58
+ // What the host's config opens to any https site stays; a view can't ask for it (P059).
59
+ if (base.anyHttps !== undefined) {
60
+ merged.anyHttps = base.anyHttps;
61
+ }
58
62
  return merged;
59
63
  }
60
64
  /** `instance` with only the fields Porthole reads, each checked: what a ticket and cookie carry. */