@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.
@@ -607,12 +607,16 @@ export const SHELL_RUNTIME = String.raw `(() => {
607
607
  const notify = (method, params) => send({ method, params });
608
608
 
609
609
  const themeNames = new Set(plan.themeVariables);
610
+ // What the content was last told of (its scheme and values): the theme it starts with is no
611
+ // change, and a host context that changes neither (a host re-sending it) is none either.
612
+ let announced = null;
610
613
  const applyTheme = (context) => {
611
614
  if (context === null || typeof context !== "object") {
612
615
  return;
613
616
  }
614
617
  // The spec's variables, and Porthole's own beside them (its own-prefixed field).
615
618
  const own = context["porthole/styles"];
619
+ const applied = [];
616
620
  for (const variables of [context.styles && context.styles.variables, own && own.variables]) {
617
621
  if (variables === null || typeof variables !== "object") {
618
622
  continue;
@@ -621,12 +625,21 @@ export const SHELL_RUNTIME = String.raw `(() => {
621
625
  const value = variables[name];
622
626
  if (themeNames.has(name) && typeof value === "string") {
623
627
  document.documentElement.style.setProperty(name, value);
628
+ applied.push(name + ":" + value);
624
629
  }
625
630
  }
626
631
  }
627
632
  lastContext = context;
628
633
  schemeFollows();
629
- window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
634
+ const now = JSON.stringify([context.theme, applied.sort()]);
635
+ if (announced === null) {
636
+ announced = now;
637
+ return;
638
+ }
639
+ if (now !== announced) {
640
+ announced = now;
641
+ window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
642
+ }
630
643
  };
631
644
 
632
645
  // No size before there is something to size: an empty shell's height would make the host
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITIES, 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
  /**
5
5
  * The frame's policies, derived from the config: its CSP (in the shell's `<meta>` for opaque
@@ -10,7 +10,12 @@ import type { EgressConfig, PortholeConfig } from "../config/schema.ts";
10
10
  /** A CSP directive: its name, then its sources. */
11
11
  export type CspDirective = readonly [name: string, ...sources: string[]];
12
12
 
13
- export type EgressKind = keyof EgressConfig;
13
+ export type EgressKind = "connect" | "script" | "style" | "img" | "font" | "media";
14
+
15
+ /** Whether `kind` loads from any https site (`EgressConfig.anyHttps`, P059): passive kinds only. */
16
+ export function anyHttps(config: PortholeConfig, kind: EgressKind): boolean {
17
+ return (config.egress.anyHttps ?? []).some((each) => each === kind);
18
+ }
14
19
 
15
20
  /** The capabilities a config declares, in vocabulary order. */
16
21
  export function declaredCapabilities(config: PortholeConfig): CapabilityName[] {
@@ -56,13 +61,15 @@ export function frameCspDirectives(config: PortholeConfig): CspDirective[] {
56
61
  return [];
57
62
  }
58
63
  const origins = (kind: EgressKind) => egressOrigins(config, kind);
64
+ // Any https site, for passive kinds a host opened so (P059); never for what runs or talks.
65
+ const https = (kind: "img" | "media" | "font") => (anyHttps(config, kind) ? ["https:"] : []);
59
66
  return [
60
67
  ["default-src", "'none'"],
61
68
  ["script-src", "'unsafe-inline'", "'unsafe-eval'", "blob:", ...origins("script")],
62
69
  ["style-src", "'unsafe-inline'", ...origins("style")],
63
- ["font-src", "data:", ...origins("font")],
64
- ["img-src", "data:", "blob:", ...origins("img")],
65
- ["media-src", "data:", "blob:", ...origins("media")],
70
+ ["font-src", "data:", ...https("font"), ...origins("font")],
71
+ ["img-src", "data:", "blob:", ...https("img"), ...origins("img")],
72
+ ["media-src", "data:", "blob:", ...https("media"), ...origins("media")],
66
73
  ["connect-src", ...list(origins("connect"))],
67
74
  ["worker-src", "blob:"],
68
75
  ["child-src", "blob:"],
@@ -61,8 +61,19 @@ export interface EgressConfig {
61
61
  readonly img?: readonly string[];
62
62
  readonly font?: readonly string[];
63
63
  readonly media?: readonly string[];
64
+ /**
65
+ * Passive content that may load from any https site (P059): images, audio and video, fonts, for
66
+ * pages that hotlink them. Never scripts, styles or data. It opens a channel out: a viewer's
67
+ * address and a visit signal reach any site, and data can leave in the URL.
68
+ */
69
+ readonly anyHttps?: readonly PassiveKind[];
64
70
  }
65
71
 
72
+ /** What `EgressConfig.anyHttps` may open: content that runs nothing (P059). */
73
+ export const PASSIVE_KINDS = ["img", "media", "font"] as const;
74
+
75
+ export type PassiveKind = (typeof PASSIVE_KINDS)[number];
76
+
66
77
  export interface ThemeConfig {
67
78
  /**
68
79
  * Every CSS variable the content may read, with its default per scheme. The shell injects
@@ -1,5 +1,10 @@
1
1
  import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
2
- import { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type PortholeConfig } from "./schema.ts";
2
+ import {
3
+ PASSIVE_KINDS,
4
+ URL_PERMISSIONS,
5
+ URL_SANDBOX_EXTRAS,
6
+ type PortholeConfig,
7
+ } from "./schema.ts";
3
8
 
4
9
  /**
5
10
  * Checks a config before anything is derived from it. Every string that ends up in a CSP, an
@@ -22,6 +27,155 @@ export class ConfigError extends Error {
22
27
  export const BUILT_IN_TOPICS = ["widget", "chart", "interactive"] as const;
23
28
 
24
29
  const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/u;
30
+
31
+ /**
32
+ * Suffixes under which anyone can register or publish a name (a public suffix): a wildcard over
33
+ * one (`https://*.co.uk`, `https://*.github.io`) opens every stranger's site. Single labels (`com`,
34
+ * `dev`) are refused by rule; these are the common multi-label ones, registries' and hosting
35
+ * platforms'. Not the whole Public Suffix List: a wildcard should name the host's own domain.
36
+ */
37
+ const PUBLIC_SUFFIXES = new Set([
38
+ // Registries' second levels.
39
+ ...["co", "org", "ac", "gov", "me", "net", "ltd", "plc", "sch", "nhs", "police"].map(
40
+ (each) => `${each}.uk`,
41
+ ),
42
+ ...["com", "net", "org", "edu", "gov", "asn", "id"].map((each) => `${each}.au`),
43
+ ...["co", "org", "net", "ac", "govt", "geek", "school"].map((each) => `${each}.nz`),
44
+ ...["co", "ne", "or", "ac", "go", "ad", "ed", "gr", "lg"].map((each) => `${each}.jp`),
45
+ ...["co", "or", "ne", "ac", "go", "re"].map((each) => `${each}.kr`),
46
+ ...["com", "net", "org", "gov", "edu"].flatMap((each) =>
47
+ [
48
+ "br",
49
+ "cn",
50
+ "hk",
51
+ "tw",
52
+ "sg",
53
+ "my",
54
+ "mx",
55
+ "ar",
56
+ "tr",
57
+ "ua",
58
+ "pl",
59
+ "ph",
60
+ "pk",
61
+ "ng",
62
+ "eg",
63
+ "sa",
64
+ "vn",
65
+ "co",
66
+ ].map((country) => `${each}.${country}`),
67
+ ),
68
+ ...["co", "net", "org", "gen", "firm", "ind", "ac", "edu", "gov", "res"].map(
69
+ (each) => `${each}.in`,
70
+ ),
71
+ ...["co", "org", "ac", "gov", "net", "web"].map((each) => `${each}.za`),
72
+ ...["co", "org", "ac", "gov", "net", "muni"].map((each) => `${each}.il`),
73
+ "co.id",
74
+ "or.id",
75
+ "ac.id",
76
+ "co.th",
77
+ "or.th",
78
+ "ac.th",
79
+ "co.ke",
80
+ "co.tz",
81
+ "co.ug",
82
+ "com.de",
83
+ "co.at",
84
+ "or.at",
85
+ "ac.at",
86
+ "co.it",
87
+ "com.es",
88
+ "nom.es",
89
+ "com.fr",
90
+ "asso.fr",
91
+ "eu.org",
92
+ "us.com",
93
+ "uk.com",
94
+ "eu.com",
95
+ "uk.net",
96
+ "gb.net",
97
+ "jpn.com",
98
+ "kr.com",
99
+ // Hosting platforms: anyone gets a name under these.
100
+ "github.io",
101
+ "githubusercontent.com",
102
+ "gitlab.io",
103
+ "bitbucket.io",
104
+ "pages.dev",
105
+ "workers.dev",
106
+ "r2.dev",
107
+ "vercel.app",
108
+ "now.sh",
109
+ "netlify.app",
110
+ "netlify.com",
111
+ "herokuapp.com",
112
+ "herokussl.com",
113
+ "appspot.com",
114
+ "web.app",
115
+ "firebaseapp.com",
116
+ "cloudfunctions.net",
117
+ "run.app",
118
+ "googleusercontent.com",
119
+ "blogspot.com",
120
+ "azurewebsites.net",
121
+ "azurestaticapps.net",
122
+ "azureedge.net",
123
+ "blob.core.windows.net",
124
+ "cloudapp.net",
125
+ "trafficmanager.net",
126
+ "cloudfront.net",
127
+ "amazonaws.com",
128
+ "s3.amazonaws.com",
129
+ "elasticbeanstalk.com",
130
+ "awsapprunner.com",
131
+ "amplifyapp.com",
132
+ "fly.dev",
133
+ "onrender.com",
134
+ "railway.app",
135
+ "up.railway.app",
136
+ "glitch.me",
137
+ "repl.co",
138
+ "replit.app",
139
+ "replit.dev",
140
+ "surge.sh",
141
+ "deno.dev",
142
+ "ngrok.io",
143
+ "ngrok-free.app",
144
+ "ngrok.app",
145
+ "loca.lt",
146
+ "trycloudflare.com",
147
+ "codesandbox.io",
148
+ "csb.app",
149
+ "stackblitz.io",
150
+ "webcontainer.io",
151
+ "neocities.org",
152
+ "wordpress.com",
153
+ "tumblr.com",
154
+ "wixsite.com",
155
+ "squarespace.com",
156
+ "myshopify.com",
157
+ "carrd.co",
158
+ "notion.site",
159
+ "framer.app",
160
+ "webflow.io",
161
+ "readthedocs.io",
162
+ "gitbook.io",
163
+ "sourceforge.net",
164
+ "digitaloceanspaces.com",
165
+ "ondigitalocean.app",
166
+ "b-cdn.net",
167
+ "jsdelivr.net",
168
+ ]);
169
+
170
+ /** Why a wildcard's base can't be wildcarded, or null: a public suffix, or a single label. */
171
+ function wildcardProblem(base: string): string | null {
172
+ if (base === "localhost") {
173
+ return null;
174
+ }
175
+ return !base.includes(".") || PUBLIC_SUFFIXES.has(base)
176
+ ? `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)`
177
+ : null;
178
+ }
25
179
  const HOST =
26
180
  /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
27
181
 
@@ -50,6 +204,12 @@ export function originSourceProblem(value: unknown): string | null {
50
204
  if (host !== "[::1]" && !HOST.test(host)) {
51
205
  return `${JSON.stringify(value)} ${shape}`;
52
206
  }
207
+ if (host.startsWith("*.")) {
208
+ const problem = wildcardProblem(host.slice(2));
209
+ if (problem !== null) {
210
+ return `${JSON.stringify(value)} ${problem}`;
211
+ }
212
+ }
53
213
  if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
54
214
  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"}`;
55
215
  }
@@ -174,10 +334,26 @@ export function validateConfig(input: unknown): PortholeConfig {
174
334
 
175
335
  const egress = object(config.egress, "config.egress");
176
336
  const kinds = ["connect", "script", "style", "img", "font", "media"];
177
- only(egress, kinds, "config.egress");
337
+ only(egress, [...kinds, "anyHttps"], "config.egress");
178
338
  for (const kind of kinds) {
179
339
  sources(egress[kind], `config.egress.${kind}`);
180
340
  }
341
+ if (egress.anyHttps !== undefined) {
342
+ if (!Array.isArray(egress.anyHttps)) {
343
+ fail("config.egress.anyHttps", `must be a list of ${PASSIVE_KINDS.join(", ")}`);
344
+ }
345
+ (egress.anyHttps as unknown[]).forEach((kind, index) => {
346
+ if (!(PASSIVE_KINDS as readonly unknown[]).includes(kind)) {
347
+ fail(
348
+ `config.egress.anyHttps[${index}]`,
349
+ `${JSON.stringify(kind)}: only images, media and fonts may load from any https site (P059), never scripts, styles or data`,
350
+ );
351
+ }
352
+ });
353
+ if (config.mode === "url") {
354
+ fail("config.egress.anyHttps", "url mode has no egress control");
355
+ }
356
+ }
181
357
 
182
358
  const theme = object(config.theme, "config.theme");
183
359
  only(theme, ["variables", "event", "documents"], "config.theme");
@@ -197,7 +197,8 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
197
197
  {
198
198
  id: "11.theme-flip",
199
199
  item: 11,
200
- title: "a theme flip re-themes the running content in place, and tells it once",
200
+ title:
201
+ "a theme flip re-themes the running content in place, and tells it once; no event without one",
201
202
  async run(context) {
202
203
  const config = inline({ theme: { event: "app:theme" } });
203
204
  const light = resolveTheme(config.theme, "light");
@@ -210,6 +211,13 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
210
211
  const frame = await display.frame();
211
212
  await expect(frame.locator("p")).toHaveText("themed");
212
213
  expect(await variable(frame, "--color-text-primary")).toBe(light["--color-text-primary"]);
214
+ const flips = () => read(frame, () => (window as unknown as { flips: string[] }).flips);
215
+ // Mounting, and a host telling it the scheme it already has, are no flip: no event (#3).
216
+ await settle(400);
217
+ expect(await flips(), "no theme event at mount").toEqual([]);
218
+ await display.setTheme("light");
219
+ await settle(400);
220
+ expect(await flips(), "no theme event for the scheme it has").toEqual([]);
213
221
  await display.setTheme("dark");
214
222
  await expect
215
223
  .poll(() => variable(frame, "--color-text-primary"))
@@ -477,6 +485,17 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
477
485
  content: PROMPT_CONTENT,
478
486
  });
479
487
  await click(display, "#ask");
488
+ // A host that always has a confirm function (a default dialog) asks anyway: what this case
489
+ // proves can't happen there (P058). Its answer is no, so nothing is sent either way.
490
+ const asked = await display.answerNextConfirmation(false, { timeoutMs: 1_500 }).then(
491
+ () => true,
492
+ () => false,
493
+ );
494
+ if (asked) {
495
+ context.inapplicable(
496
+ "this host always has a confirm function (a default dialog), so a display without one can't be shown; it asked the person instead",
497
+ );
498
+ }
480
499
  expect(await bodyData(display, "result")).toBe("false");
481
500
  expect(await refusals(display)).toContain("sendPrompt:no-confirm");
482
501
  expect(await display.calls()).toEqual([]);
@@ -22,6 +22,11 @@ export interface ProbeContext {
22
22
  requestsTo(origin: string): string[];
23
23
  /** Records a measured engine fact in the report. */
24
24
  measured(text: string): void;
25
+ /**
26
+ * Ends the probe as inapplicable on this host (reported as skipped, with the reason): what it
27
+ * proves can't be observed here, through no fault of the host's isolation (P058).
28
+ */
29
+ inapplicable(reason: string): never;
25
30
  }
26
31
 
27
32
  export interface Probe {
@@ -49,6 +54,17 @@ export function attack(code: string, markup = ""): string {
49
54
  (async () => {
50
55
  const r = {};
51
56
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
57
+ // Whether a policy blocks \`origin\` though the frame's own allows it: then it is the page's,
58
+ // which an opaque frame inherits (P058). \`attempt\` loads something from it once more.
59
+ const inheritedBlocks = async (origin, attempt) => {
60
+ let blocked = false;
61
+ const seen = (event) => { if (String(event.blockedURI).indexOf(origin) === 0) blocked = true; };
62
+ document.addEventListener("securitypolicyviolation", seen);
63
+ try { await attempt(); } catch (e) {}
64
+ await wait(300);
65
+ document.removeEventListener("securitypolicyviolation", seen);
66
+ return blocked;
67
+ };
52
68
  try {
53
69
  ${code}
54
70
  } catch (error) {
@@ -335,6 +335,13 @@ const sdkProbe = (preset: "inline" | "artifact" | "mcp-app"): Probe => ({
335
335
  export type { Probe, ProbeContext } from "./kit.ts";
336
336
  export { attack } from "./kit.ts";
337
337
 
338
+ /**
339
+ * Why a positive control can't pass on this host: an opaque (`srcdoc`) frame inherits the
340
+ * embedding page's whole policy, and the page's own closes what the config opens (P058).
341
+ */
342
+ const INHERITED = (directive: string, origin: string) =>
343
+ `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.`;
344
+
338
345
  export const PROBES: readonly Probe[] = [
339
346
  // ── 0: the shell's own wiring, in a real frame ─────────────────────────────────────────────
340
347
  {
@@ -440,10 +447,14 @@ export const PROBES: readonly Probe[] = [
440
447
  worker.onmessage = (event) => (event.data === "sent" ? resolve() : reject());
441
448
  worker.onerror = reject;
442
449
  }));
443
- r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");`),
450
+ r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");
451
+ r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => fetch(${js(allowed.origin)} + "/policy").catch(() => {}));`),
444
452
  });
445
453
  const verdict = await context.result(display, 20_000);
446
454
  await settle();
455
+ if (verdict.allowed !== "oracle" && verdict.inherited === true) {
456
+ context.inapplicable(INHERITED("connect-src", allowed.origin));
457
+ }
447
458
  expect(verdict.allowed, "the allowed origin (the positive control)").toBe("oracle");
448
459
  expect(allowed.requests().map((request) => request.path)).toContain("/allowed");
449
460
  expect(canary.requests(), "requests reached the canary").toEqual([]);
@@ -489,12 +500,16 @@ export const PROBES: readonly Probe[] = [
489
500
  try { new Worker(${js(`${c}/worker.js`)}); } catch (e) {}
490
501
  const ok = document.getElementById("ok");
491
502
  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); });
492
- r.script = typeof globalThis.__portholeOracleScript;`,
503
+ r.script = typeof globalThis.__portholeOracleScript;
504
+ r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => { new Image().src = ${js(`${allowed.origin}/policy.png`)}; });`,
493
505
  markup,
494
506
  ),
495
507
  });
496
508
  const verdict = await context.result(display, 20_000);
497
509
  await settle();
510
+ if (verdict.allowed !== "loaded" && verdict.inherited === true) {
511
+ context.inapplicable(INHERITED("img-src", allowed.origin));
512
+ }
498
513
  expect(verdict.allowed, "the allowed image (the positive control)").toBe("loaded");
499
514
  expect(canary.requests(), "resources loaded from the canary").toEqual([]);
500
515
  expect(context.requestsTo(canary.origin)).toEqual([]);
@@ -1106,6 +1121,10 @@ export function leakTests(
1106
1121
  requestsTo: (origin) =>
1107
1122
  requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
1108
1123
  measured: (text) => info.annotations.push({ type: "measured", description: text }),
1124
+ inapplicable: (why) => {
1125
+ info.skip(true, `inapplicable on this host: ${why}`);
1126
+ throw new Error(`inapplicable on this host: ${why}`);
1127
+ },
1109
1128
  });
1110
1129
  } finally {
1111
1130
  await Promise.all(closers.map((close) => close()));
@@ -380,33 +380,85 @@ export function servedTests(
380
380
 
381
381
  define(
382
382
  "served.domain.egress",
383
- "the CSP in the document's headers closes egress",
383
+ "the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it",
384
384
  async ({ page }) => {
385
385
  const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
386
386
  const frame = await display.frame();
387
- const fetched = await frame.evaluate(
388
- async (url) => {
389
- try {
390
- await fetch(url, { mode: "no-cors" });
391
- return "sent";
392
- } catch {
393
- return "blocked";
394
- }
395
- },
396
- await host.canary("egress-fetch"),
397
- );
398
- await frame.evaluate(
399
- (url) => {
400
- const image = new Image();
401
- image.src = url;
402
- document.body.append(image);
403
- },
404
- await host.canary("egress-img"),
405
- );
387
+ const kinds = ["fetch", "img", "media", "script", "style", "frame"] as const;
388
+ const urls = Object.fromEntries(
389
+ await Promise.all(kinds.map(async (kind) => [kind, await host.canary(`egress-${kind}`)])),
390
+ ) as Record<(typeof kinds)[number], string>;
391
+ // Each kind tried once; the policy that applied is read from the violations it raises.
392
+ const seen = await frame.evaluate(async (each) => {
393
+ const policies: string[] = [];
394
+ document.addEventListener("securitypolicyviolation", (event) =>
395
+ policies.push(event.originalPolicy),
396
+ );
397
+ let fetched = "sent";
398
+ try {
399
+ await fetch(each.fetch, { mode: "no-cors" });
400
+ } catch {
401
+ fetched = "blocked";
402
+ }
403
+ const image = new Image();
404
+ image.src = each.img;
405
+ document.body.append(image);
406
+ const audio = document.createElement("audio");
407
+ audio.preload = "auto";
408
+ audio.src = each.media;
409
+ document.body.append(audio);
410
+ const script = document.createElement("script");
411
+ script.src = each.script;
412
+ document.body.append(script);
413
+ const link = document.createElement("link");
414
+ link.rel = "stylesheet";
415
+ link.href = each.style;
416
+ document.head.append(link);
417
+ const nested = document.createElement("iframe");
418
+ nested.src = each.frame;
419
+ document.body.append(nested);
420
+ await new Promise((resolve) => setTimeout(resolve, 800));
421
+ return { fetched, policy: policies.find((policy) => /connect-src/u.test(policy)) ?? "" };
422
+ }, urls);
406
423
  await page.waitForTimeout(SETTLE_MS);
407
- expect(fetched).toBe("blocked");
408
- expect(await host.canaryHits("egress-fetch")).toBe(0);
409
- expect(await host.canaryHits("egress-img")).toBe(0);
424
+ expect(seen.fetched).toBe("blocked");
425
+ expect(seen.policy, "a CSP applied to the document").not.toBe("");
426
+ const sources = new Map(
427
+ seen.policy
428
+ .split(";")
429
+ .map((directive) => directive.trim().split(/\s+/u))
430
+ .map(([name = "", ...rest]) => [name, rest] as const),
431
+ );
432
+ // What runs or talks never opens to any site (P059), whatever the config.
433
+ for (const name of ["script-src", "style-src", "connect-src", "frame-src", "default-src"]) {
434
+ expect(sources.get(name) ?? [], `${name} opens no scheme`).not.toContain("https:");
435
+ }
436
+ for (const kind of ["fetch", "script", "style", "frame"] as const) {
437
+ expect(await host.canaryHits(`egress-${kind}`), kind).toBe(0);
438
+ }
439
+ // Images and media: closed, unless the config opened them to any https site (P059);
440
+ // then, the canary being an https site, an image reaches it, as the config means.
441
+ const https = (kind: "img" | "media") =>
442
+ (sources.get(`${kind}-src`) ?? []).includes("https:") &&
443
+ new URL(urls[kind]).protocol === "https:";
444
+ const opened = (["img", "media"] as const).filter(https);
445
+ if (opened.length > 0) {
446
+ info?.annotations.push({
447
+ type: "expected",
448
+ 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`,
449
+ });
450
+ }
451
+ if (https("img")) {
452
+ expect(
453
+ await host.canaryHits("egress-img"),
454
+ "the image, as the config opens it",
455
+ ).toBeGreaterThan(0);
456
+ } else {
457
+ expect(await host.canaryHits("egress-img"), "img").toBe(0);
458
+ }
459
+ if (!https("media")) {
460
+ expect(await host.canaryHits("egress-media"), "media").toBe(0);
461
+ }
410
462
  },
411
463
  );
412
464
 
@@ -7,6 +7,7 @@ import {
7
7
  import type { PortholeConfig } from "../config/schema.ts";
8
8
  import { BUILT_IN_TOPICS } from "../config/validate.ts";
9
9
  import {
10
+ anyHttps,
10
11
  declaredCapabilities,
11
12
  effectiveGates,
12
13
  egressOrigins,
@@ -83,11 +84,20 @@ const KIND_WORDS: Readonly<Record<EgressKind, string>> = {
83
84
  connect: "data (fetch, WebSocket)",
84
85
  };
85
86
 
87
+ /** What the manual calls `https:` (any https site), where a config opens passive kinds to it. */
88
+ export const ANY_HTTPS_SITE = "any https site";
89
+
90
+ /** The sources content may load `kind` from, as the manual names them: origins, any https site. */
91
+ const sourcesFor = (config: PortholeConfig, kind: EgressKind): string[] => [
92
+ ...(anyHttps(config, kind) ? [ANY_HTTPS_SITE] : []),
93
+ ...egressOrigins(config, kind),
94
+ ];
95
+
86
96
  /** What content may load, in prose, from the same origins the CSP lists. */
87
97
  function network(config: PortholeConfig): string {
88
98
  const kinds: EgressKind[] = ["script", "style", "font", "img", "media", "connect"];
89
99
  const open = kinds
90
- .map((kind) => [kind, egressOrigins(config, kind)] as const)
100
+ .map((kind) => [kind, sourcesFor(config, kind)] as const)
91
101
  .filter(([, origins]) => origins.length > 0);
92
102
  if (open.length === 0) {
93
103
  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.";
@@ -419,10 +429,19 @@ export function generateToolDescription(config: PortholeConfig): string {
419
429
  ),
420
430
  ),
421
431
  ];
432
+ const passive = (["img", "media", "font"] as const)
433
+ .filter((kind) => anyHttps(config, kind))
434
+ .map((kind) => KIND_WORDS[kind]);
435
+ const fromAnywhere =
436
+ passive.length === 0
437
+ ? ""
438
+ : `${passive.length === 1 ? passive[0] : `${passive.slice(0, -1).join(", ")} and ${passive.at(-1)}`} from ${ANY_HTTPS_SITE}`;
422
439
  const net =
423
- origins.length === 0
440
+ origins.length === 0 && fromAnywhere === ""
424
441
  ? "with no network (nothing loads from outside)"
425
- : `loading only from ${origins.join(", ")}`;
442
+ : origins.length === 0
443
+ ? `loading only ${fromAnywhere}`
444
+ : `loading only from ${origins.join(", ")}${fromAnywhere === "" ? "" : `, and ${fromAnywhere}`}`;
426
445
  const globals = declaredCapabilities(config).flatMap((name) => {
427
446
  const spec = capabilitySpec(name);
428
447
  if (spec.global === undefined || !c.global(spec.global)) {
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export type {
33
33
  ThemeConfig,
34
34
  ThemeValue,
35
35
  } from "./config/schema.ts";
36
+ export { PASSIVE_KINDS, type PassiveKind } from "./config/schema.ts";
36
37
  export {
37
38
  BUILT_IN_TOPICS,
38
39
  ConfigError,
@@ -62,6 +63,7 @@ export {
62
63
  export { compile, type CompiledConfig } from "./compile/compile.ts";
63
64
  export {
64
65
  allEgressOrigins,
66
+ anyHttps,
65
67
  declaredCapabilities,
66
68
  effectiveGates,
67
69
  egressOrigins,
@@ -126,6 +126,10 @@ const mergeEgress = (base: EgressConfig, extra: EgressConfig | undefined): Egres
126
126
  merged[kind] = origins;
127
127
  }
128
128
  }
129
+ const passive = [...new Set([...(base.anyHttps ?? []), ...(extra?.anyHttps ?? [])])];
130
+ if (passive.length > 0) {
131
+ merged.anyHttps = passive;
132
+ }
129
133
  return merged;
130
134
  };
131
135