diff --git a/.gitignore b/.gitignore index d688e5b..9f66eb2 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,10 @@ scene-*.png probe*.png g-pi-*.png cv-*.png +cg2-*.png + +# Ground Control SonarCloud server-side analytical reports +.gc/sonar/ # Local-only decks (per-developer scratch, never check in) src/decks/local-*/ diff --git a/README.md b/README.md index 49a301a..575f88d 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,7 @@ The runtime owns the scene/composition model. External libraries are adopted where they make a specific layer stronger, not where they force the project back into slide semantics. -See `docs/adrs/` for architectural decisions. +See `docs/adrs/` for architectural decisions. The trust model for +scene modules — what the runtime treats as trusted application code, +and what current validation does not cover — is documented in +[`docs/scene-trust-model.md`](docs/scene-trust-model.md). diff --git a/changelog.d/+terminal-scene-audio.added.md b/changelog.d/+terminal-scene-audio.added.md new file mode 100644 index 0000000..43623e2 --- /dev/null +++ b/changelog.d/+terminal-scene-audio.added.md @@ -0,0 +1,11 @@ +The `terminal` L2 template accepts an optional `audio` soundtrack. A +terminal scene can name a source URL, sound id, volume, playback rate, +and fade-out duration; the template declares the URL in `scene.assets` ++ `scene.audio`, loads and plays the track through `ctx.audio` when the +script begins, and fades it to silence on scene teardown. + +The audio integration is exposed as the standalone helper +`startTerminalAudio(audio, config)`, returning the fade-and-stop closure +the template registers on its per-scene session. Decks that need to +drive the same load → play → fade-out shape from a different template +can call the helper directly. diff --git a/changelog.d/100.changed.md b/changelog.d/100.changed.md new file mode 100644 index 0000000..5f10071 --- /dev/null +++ b/changelog.d/100.changed.md @@ -0,0 +1,11 @@ +Audited runtime source comments and rewrote stale future-work +language across `src/main.ts`, `src/runtime/{prompter,scene-loader, +timeline,presenter,audio}.ts`, and `src/system/presenter/prompter- +window.ts`. Outdated phrases naming a placeholder timeline runner, an +unwired cue gate, an uninterpreted presenter command controller, or a +captions/script UI surface "until the renderer lands" now describe +the wired adapters (PUL-F017 / PUL-F020 / PUL-F021 / PUL-F022 / +PUL-F024 / `createChromePrompterRenderer`). Source-policy comments +(`// PUL-*-allow:`, `// biome-ignore`), runtime behavior, exported +types, error strings, DOM attributes, validation, and tests are +unchanged. diff --git a/docs/adrs/012-asset-preloader-fetch-and-drain.md b/docs/adrs/012-asset-preloader-fetch-and-drain.md index 5e6d5d1..9239d97 100644 --- a/docs/adrs/012-asset-preloader-fetch-and-drain.md +++ b/docs/adrs/012-asset-preloader-fetch-and-drain.md @@ -212,6 +212,34 @@ not need to change for that layer to land. | `init.signal` cancellation arrives mid-drain and the preloader propagates a partial failure. | The current behavior surfaces the cancellation as one entry in `AggregateError.errors`. Callers driving cancellation from outside (e.g. the workbench during scene navigation) are responsible for treating cancellation as a non-fatal abort separate from genuine asset failures. | | SSRF via `scene.assets` declaring loopback / link-local IPs (e.g. `http://127.0.0.1:...`, `http://169.254.169.254/`). | The scheme allowlist alone does not block these; `http:` and `https:` allow any host. Production deployments wanting host-level defense need to either restrict at the network layer (egress firewall blocking RFC 1918 / RFC 6890 ranges) or wait for a future `originAllowlist` option on the preloader. Surfaces processing untrusted scene metadata MUST adopt one of these defenses. | +## Production profile + +The preloader's defaults are authoring defaults +(`DEFAULT_ALLOWED_SCHEMES = ['http:', 'https:', 'data:', 'blob:']`, +no `baseUrl`, no custom `fetch`). Deployments opt into the hardened +production profile at the entrypoint by passing +`{ baseUrl, allowedSchemes: ['https:'], fetch?: }` +to **both** `createAssetPreloader` and `validateRuntime`. + +The canonical deployment-facing policy doc is +[`docs/asset-url-policy.md`](../asset-url-policy.md). It covers: + +- when `baseUrl` is required (every production entrypoint); +- which schemes are allowed in public production (`https:` only) and + why each forbidden scheme is forbidden; +- how credentialed `fetch` options must be bound per destination URL + rather than via shared `init.headers`; +- post-redirect scheme re-validation as a non-negotiable gate; +- the SSRF caveat — scheme allowlisting is not host hardening, and + deployments processing untrusted scene metadata still need network + egress controls; +- the named future seams (`allowedOrigins`, `credentialedOrigins`) + for any runtime tightening beyond the current behavior. + +Future changes to accepted production policy SHOULD update both the +policy doc and this ADR; the policy doc is the operational source of +truth, the ADR is the decision record. + ## Related ADRs - [ADR-002](002-scene-registry-and-compositions.md) §Resolution — @@ -223,3 +251,9 @@ not need to change for that layer to land. whose `AssetPreloader` adapter slot this preloader fills. - [ADR-007](007-browser-workbench.md) — the workbench whose `mode=screenshot` is the future customer of decode-complete. + +## Related docs + +- [`docs/asset-url-policy.md`](../asset-url-policy.md) — production + policy for `baseUrl`, schemes, credentials, redirects, and SSRF + posture. diff --git a/docs/asset-url-policy.md b/docs/asset-url-policy.md new file mode 100644 index 0000000..d91410c --- /dev/null +++ b/docs/asset-url-policy.md @@ -0,0 +1,304 @@ +# Asset URL and credential policy (production) + +This is the deployment-facing policy for how Pulsar's asset preloader +should be configured in production. The runtime ships with authoring +defaults that are deliberately permissive; production entrypoints opt +into the hardened profile here. + +The canonical preloader decision is +[ADR-012](adrs/012-asset-preloader-fetch-and-drain.md). The +implementation lives in `src/runtime/asset-preloader.ts` and is mirrored +by `src/runtime/validation.ts` for static checks. This document does +not introduce new runtime knobs — it tells deployments how to compose +the existing ones. + +## Scope + +- Asset preloader (`createAssetPreloader`) — the only runtime fetch + path for declared scene assets. Workbench bootstrap, exporter, and + any future deployment entrypoint pass options through it. +- Runtime validation (`validateRuntime`) — receives the same + `assets: { baseUrl, allowedSchemes }` block so static checks reject + what the preloader would reject. Authoring/CI gates run identically + to production gates. +- Audio source policy + (`src/runtime/audio.ts` → `resolveAssetUrl`) — reuses the same + scheme allowlist, so tightening at the asset layer flows through. + +Out of scope: scene metadata shape, composition manifest shape, +workbench mode grammar, exporter caching strategy, telemetry, and any +new configuration loader. These are explicitly non-goals (see +[issue #101 preflight](design/issue-101-production-asset-policy-preflight.md)). + +## The production profile + +A hardened public production deployment SHOULD configure both the +preloader and the validation pass like this: + +```ts +const ASSET_POLICY = { + baseUrl: 'https://assets.example.com/', + allowedSchemes: ['https:'] as const, +}; + +// Boot/CI validation — same policy, no I/O. +const findings = validateRuntime({ + scenes: WORKBENCH_SCENES, + compositions: WORKBENCH_COMPOSITIONS, + assets: ASSET_POLICY, +}); +assertNoValidationFindings(findings); + +// Runtime preloader — per-navigation, with the abort signal. +const createPreloader = (signal: AbortSignal) => + createAssetPreloader({ + ...ASSET_POLICY, + init: { signal }, // see "Credentialed fetches" below — do NOT + // attach Authorization / Cookie here in + // production deployments that load any + // third-party origin. + }); +``` + +Today `src/main.ts` runs with authoring defaults — no `baseUrl`, +`DEFAULT_ALLOWED_SCHEMES` (`http:`, `https:`, `data:`, `blob:`), no +custom `fetch`. A deployment that wants the hardened profile passes +the matching policy object to **both** `createAssetPreloader` and +`validateRuntime`. Splitting them is the failure mode this section +exists to prevent. + +## Required `baseUrl` + +Production MUST set `baseUrl`. + +- A configured `baseUrl` makes every declared asset resolve to an + absolute URL via `URL(asset, baseUrl)`. The preloader then + scheme-checks the resolved URL up front — before any `fetch` runs + ([asset-preloader.ts `resolveAssetUrl`](../src/runtime/asset-preloader.ts)). +- Without `baseUrl`, relative paths (`'/foo.png'`, `'images/x.png'`) + fall through to the browser's `document.baseURI`, whose protocol + the preloader cannot statically validate. A tightened `allowedSchemes` + becomes advisory in that path; the production profile MUST NOT rely + on it. ADR-012 §Negative documents the trade-off; the production + position is "set `baseUrl`." +- Protocol-relative URLs (`'//host/path'`) without `baseUrl` are + rejected outright by the preloader — there is no static way to know + the scheme. With `baseUrl` set, they resolve against it and the + resolved scheme is re-checked. +- Authoring and the local workbench may omit `baseUrl` for browser + convenience. The production opt-in is at the deployment entrypoint; + the runtime does not gate the dev/authoring path. + +## Allowed schemes + +| Scheme | Production posture | Why | +|--------|-------------------|------| +| `https:` | **Allow.** Default for public production. | Cacheable, inspectable by normal deployment controls, no mixed-content risk, no network-layer tampering. | +| `http:` | **Disallow in public production.** Allow only for explicitly local or private deployments that accept transport risk. | Plaintext, mixed-content-blocked in `https:`-served pages, no integrity. | +| `data:` | **Disallow in hardened public production.** Allow only for a documented trusted-inline profile where scene metadata is fully trusted. | Bypasses CDN/origin allowlisting, deployment scanning, cache policy, and stable URL review. | +| `blob:` | **Disallow in hardened public production.** Allow only when scenes assemble their own bytes locally (rare; not the workbench case today). | Same reasoning as `data:` — opaque to deployment controls. | +| `file:` | **Forbid.** | Local filesystem exposure. | +| `javascript:` | **Forbid.** | Code execution surface; not a fetch URL. | +| `ftp:`, `gopher:` | **Forbid.** | Not how production assets are delivered; legacy and unauthenticated. | +| `ws:`, `wss:` | **Forbid.** | Not byte-warming targets; the preloader's contract is `fetch` + drain. | + +The default `DEFAULT_ALLOWED_SCHEMES` constant +(`['http:', 'https:', 'data:', 'blob:']`) is the authoring default and +MUST NOT be mutated to express a production deployment choice — the +constant is shared by validation, audio, and any future surface, and +tightening it globally would break authoring. Entrypoints opt in by +passing an explicit `allowedSchemes` array. + +## Credentialed `fetch` and third-party origins + +`AssetPreloaderOptions.init` is forwarded to **every** `fetch` call +for the scene. That makes shared `init.headers` and +`credentials: 'include'` a global property of the per-scene preload, +not a per-URL property. + +Production rules: + +- **DO NOT** attach `Authorization`, `Cookie`, or other + credential-bearing headers via `init.headers` when **any** asset in + the deployment can resolve to a third-party origin. Browsers and + Node will send those credentials to every absolute URL the + preloader fetches. +- **DO NOT** set `credentials: 'include'` via `init` when third-party + assets are possible. CORS will block most of these anyway, but the + intent must be wrong if the deployment relies on the block. +- **DO** bind credentials per destination URL by passing a custom + `fetch` to `AssetPreloaderOptions.fetch`. The custom function + inspects the request URL, attaches credentials only for same-origin + or an explicit allowlist, and refuses (or strips) for everything + else. The preloader treats the returned `Response` the same as + default `fetch`'s. + +```ts +const ALLOWED_CREDENTIALED_ORIGINS = new Set([ + 'https://assets.example.com', +]); + +const guardedFetch: typeof fetch = (input, init) => { + // Pull the request URL out of every shape the Fetch API accepts. + // `Request.toString()` is "[object Request]" — use `request.url`; + // `URL.toString()` happens to work, but `URL.href` is the explicit + // contract. + const requestUrl = + input instanceof Request + ? input.url + : input instanceof URL + ? input.href + : input; + const url = new URL(requestUrl); + if (!ALLOWED_CREDENTIALED_ORIGINS.has(url.origin)) { + return fetch(input, { ...init, credentials: 'omit', headers: undefined }); + } + return fetch(input, init); +}; +``` + +Production deployments that need credentialed asset delivery pass this +shape (or an equivalent) as `AssetPreloaderOptions.fetch`. The shape +of the policy — origin allowlist plus a custom fetch — is the seam +ADR-012 reserves for credential handling; a future preloader option +(`credentialedOrigins`) would formalize it, but the seam works today. + +## Redirect handling + +The preloader uses the default `redirect: 'follow'` on every fetch +and re-validates the final URL after redirects: + +- If `response.url` differs from the requested URL and its scheme is + not in `allowedSchemes`, the asset rejects with + `asset "": redirected to disallowed URL ""`. The + response body is cancelled before throwing (no connection leak). +- Same-scheme redirects (`https:` → `https:`) pass through. + +Production rules: + +- **DO NOT** downgrade to `redirect: 'manual'` to dodge the post- + redirect check. The cost of the check is a single string comparison + per asset; the value is catching `https://allowed.example` → 302 → + `file:///etc/passwd`. +- **DO NOT** rely solely on the up-front scheme check. Up-front + validation and post-redirect validation are independent gates; + removing either weakens the contract. +- If a future surface adds an origin allowlist + (`AssetPreloaderOptions.allowedOrigins`), it MUST run the same + up-front + post-redirect pair. The preloader's seam keeps the two + checks symmetric. + +## SSRF caveat (scheme is not host hardening) + +`allowedSchemes: ['https:']` blocks transport-layer downgrades — it +does NOT block loopback (`127.0.0.1`), link-local (`169.254.0.0/16`), +RFC 1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), the cloud +metadata endpoint (`169.254.169.254`), or any private host that +happens to serve HTTPS. + +Production deployments that process **untrusted scene metadata** (a +hosted workbench accepting third-party scenes, a content surface +that reads scene URLs from user input, etc.) MUST also: + +- Restrict egress at the network layer — a firewall, security group, + or sidecar proxy that blocks RFC 1918 / RFC 6890 ranges and the + cloud metadata endpoint from the host running the preload. +- OR wait for a future `allowedOrigins` option on the preloader and + apply it once that exists. + +For first-party-only deployments where the scene catalog is +deployment-owned, the egress layer is the only defense the project +ships today. The policy doc and ADR-012 name this explicitly so a +contributor reading "we allow `https:`" does not infer host +hardening. + +## Validation parity (boot and CI agree with runtime) + +The runtime validation pass (`validateRuntime`, `src/runtime/validation.ts`) +accepts the same `assets: { baseUrl, allowedSchemes }` block as the +preloader and runs the same `resolveAssetUrl` rule. Production rules: + +- **DO** pass the production `assets` policy to `validateRuntime` at + boot (`src/main.ts`) and in the CI gate + (`tests/runtime/workbench-graph.test.ts`). A misconfigured asset + fails the validation pass before any lifecycle effect rather than + surfacing as a per-scene preload rejection at the first navigation. +- **DO NOT** let the validator use authoring defaults while the + preloader uses hardened settings — that produces "passes + validation, fails on first navigation" inconsistency. + +## Diagnostics envelope (what the policy is allowed to log) + +Per ADR-012's error semantics, asset failures aggregate per scene into +a single `AggregateError` whose per-asset messages name the offending +URL (and the resolved URL when different). Production diagnostics: + +- **DO** keep the existing message grammar: + `asset "": ...` or `asset "" → "": ...`. +- **DO NOT** include request headers, cookies, Authorization values, + full response objects, stack traces, environment values, raw scene + metadata, or DOM nodes in diagnostics. The preloader already + honors this rule; a custom `fetch` MUST too. +- **DO NOT** pass credential-bearing URLs through process argv, shell + commands, filenames, CI annotations, or artifacts. Tests use + injected fake fetches and in-memory policy objects (see + `tests/runtime/asset-preloader.test.ts`). + +## Existing structural gates (what the runtime already enforces) + +Each policy rule above is anchored by an executable check the project +already ships. If any of these regress, the policy claim regresses +with them. + +| Policy rule | Runtime gate | +|-------------|--------------| +| Scheme allowlist enforced before fetch | `tests/runtime/asset-preloader.test.ts` "scheme allowlist (SSRF defense)" — `file:`, `gopher:`, tightened `['https:']`, baseUrl-resolved scheme re-check (`:548-606`) | +| Post-redirect scheme re-validation | `tests/runtime/asset-preloader.test.ts` "post-redirect URL re-validation" — redirect to `file:` rejected, same-scheme redirect accepted (`:251-291`) | +| Protocol-relative URLs without `baseUrl` rejected | `tests/runtime/asset-preloader.test.ts` "protocol-relative URLs" (`:293-307`) | +| `baseUrl` resolution + resolved-scheme re-check | `tests/runtime/asset-preloader.test.ts` "baseUrl resolution (Node-portable)" (`:499-528`) | +| Validation pass mirrors preloader policy | `tests/runtime/validation.test.ts` asset-resolvable checks share `resolveAssetUrl` and `DEFAULT_ALLOWED_SCHEMES` | +| Audio source policy reuses scheme allowlist | `src/runtime/audio.ts` resolves audio source URLs through `resolveAssetUrl` + `DEFAULT_ALLOWED_SCHEMES` (no copy of the rules) | + +## Future extensions (named seams; do not invent parallels) + +If a future deployment needs runtime enforcement beyond what +`baseUrl` + `allowedSchemes` + a custom `fetch` cover, extend the +existing `AssetPreloaderOptions` rather than create a parallel asset +configuration system. The named seams ADR-012 reserves: + +- `allowedOrigins` — host-level allowlist for first-party and CDN + origins. The check runs both up front (on `resolveAssetUrl`'s + output) and post-redirect (on `response.url`) so the gate is + symmetric with `allowedSchemes`. +- `credentialedOrigins` — explicit destinations allowed to receive + `Authorization`, cookies, or `credentials: 'include'`. Folds the + custom-`fetch` pattern documented above into a structural option. +- Shared policy object — `validateRuntime` consumes the same shape + via `ValidationInput.assets`. Any new field MUST mirror in both + surfaces so static and runtime gates remain symmetric. + +Anti-patterns this doc rules out (per preflight): + +- `ProductionAssetPreloader`, `AssetPolicyValidator`, or a parallel + exception hierarchy. +- A new asset manifest, scene DTO, configuration loader, env-binding + layer, deployment CLI, or telemetry surface. +- Mutating `DEFAULT_ALLOWED_SCHEMES` to express a deployment choice. +- Banning relative asset strings in `scene.assets` — production + strictness belongs in `baseUrl` + policy options, not authoring + metadata. +- Encoding production policy in scene modules (any per-scene + `assets: { allowedSchemes: ... }` field). +- Treating `data:`/`blob:` as "safe because no network." They bypass + deployment controls and remain opt-in for trusted profiles. + +## Related + +- [ADR-012 — asset preloader fetch + drain](adrs/012-asset-preloader-fetch-and-drain.md) +- [Issue #101 preflight](design/issue-101-production-asset-policy-preflight.md) +- [`docs/scene-trust-model.md`](scene-trust-model.md) — scene module + trust model. Asset URL policy is a URL and credential gate; it is + not a sandbox for scene code. Untrusted scene-module execution is a + separate, currently-non-existent surface tracked there. +- `src/runtime/asset-preloader.ts` — `createAssetPreloader`, `resolveAssetUrl`, `DEFAULT_ALLOWED_SCHEMES`. +- `src/runtime/validation.ts` — `validateRuntime`, `ValidationInput.assets`. diff --git a/docs/design/README.md b/docs/design/README.md index 4453758..9fe92bd 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,9 @@ Design context for Pulsar. Source material for the ADRs in `../adrs/`. | [issue-097-browser-runtime-smoke-preflight.md](issue-097-browser-runtime-smoke-preflight.md) | Guardrails for expanding browser runtime smoke coverage through the existing Playwright workbench gate. | | [issue-098-vertical-slice-demo-preflight.md](issue-098-vertical-slice-demo-preflight.md) | Guardrails for adding authored demo scenes that exercise composition, timeline, captions, assets, and workbench routing through existing runtime seams. | | [issue-099-repeated-scene-activation-context-preflight.md](issue-099-repeated-scene-activation-context-preflight.md) | Guardrails for supporting repeated scene entries through per-occurrence activation ownership without changing scene, composition, URL, registry, timeline, or audio boundaries. | +| [issue-100-runtime-comment-audit-preflight.md](issue-100-runtime-comment-audit-preflight.md) | Guardrails for auditing stale runtime comments without changing behavior, duplicating ADR rationale, or weakening source-policy comments. | +| [issue-101-production-asset-policy-preflight.md](issue-101-production-asset-policy-preflight.md) | Guardrails for documenting and optionally enforcing production asset URL, redirect, base URL, cross-origin, and credential policy through the existing preloader and validation seams. | +| [issue-102-scene-module-trust-boundary-preflight.md](issue-102-scene-module-trust-boundary-preflight.md) | Guardrails for documenting that scene modules are trusted application code and that validation/source/asset policies are not sandboxing. | | [positioning-and-landscape.md](positioning-and-landscape.md) | Category, adjacent OSS projects, differentiation, strategic risks. | | [pul-p002-validation-ci-preflight.md](pul-p002-validation-ci-preflight.md) | Guardrails for gating pull-request CI on the canonical runtime validation pass. | | [pul-p003-adr-format-preflight.md](pul-p003-adr-format-preflight.md) | Guardrails for keeping ADR markdown and Ground Control ADR records aligned without duplicate schemas or workflow logic. | diff --git a/docs/design/issue-100-runtime-comment-audit-preflight.md b/docs/design/issue-100-runtime-comment-audit-preflight.md new file mode 100644 index 0000000..448e44f --- /dev/null +++ b/docs/design/issue-100-runtime-comment-audit-preflight.md @@ -0,0 +1,135 @@ +# Issue 100 Runtime Comment Audit Preflight + +Date: 2026-05-23 + +Issue 100 is source-comment hygiene. The change should make runtime +comments trustworthy without changing runtime behavior or moving broad +architecture rationale back into code. ADRs and design docs remain the +durable home for system-level decisions; source comments should explain +local invariants, boundary contracts, machine-read policy exceptions, +and non-obvious constraints. + +No new ADR is needed. Existing ADRs already decide the runtime seams: +scene and composition contracts (ADR-002 / ADR-008), timeline and audio +adapters (ADR-003 / ADR-004 / ADR-025), workbench URL dispatch +(ADR-007 / ADR-013 / ADR-014), validation (ADR-008 / PUL-F028), error +surfaces (ADR-028 plus PUL-Q006 / PUL-Q009), and chrome/workbench +ownership (ADR-031). + +## Boundary + +- Scope the audit to runtime-authored source under `src/main.ts`, + `src/runtime/**`, `src/system/**`, `src/scenes/**`, and + `src/compositions/**`, including TypeScript and runtime CSS comments. + Tests and docs may be consulted to verify status, but they are not + the primary target. +- Review comments containing `future`, `placeholder`, `not yet`, + `follow-up`, `OMITTED`, `TODO`, `FIXME`, ADR references, and PUL + references. A match is not automatically stale; decide from the + current code and the relevant ADR/design doc. +- Keep comments that state local invariants, security boundaries, + policy-exemption rationale, ownership boundaries, or failure + envelopes. Trim broad history and duplicated ADR prose when the code + nearby is self-evident. +- Do not change exported types, runtime strings, DOM attributes, + validation logic, policies, source scanners, tests, build config, or + behavior unless a comment exposes a real defect. If that happens, + keep the behavioral fix focused and test it as a defect, not as a + comment-audit side effect. +- This requirement-free issue does not create or transition Ground + Control requirements and does not create IMPLEMENTS / TESTS + traceability links. + +## Required Reuse + +- Durable rationale: `docs/adrs/**`, `docs/design/**`, + `docs/design/README.md`, and `docs/requirements/conventions.md`. +- Workflow policy: `.ground-control.yaml`, `.gc/plan-rules.md`, + `AGENTS.md`, and `changelog.d/README.md`. +- Validation/schema incumbents: `assertSceneModule()`, + `assertCompositionManifest()`, `createIdRegistry()`, + `validateRuntime()`, `resolveAssetUrl()`, `PRESENTER_COMMAND_KINDS`, + `isPresenterCommand()`, `formatSceneContext()`, + `describeError()`, and `describeErrorDetailed()`. +- Policy-source incumbents: `tests/runtime/source-policy.ts`, + `tests/runtime/screenshot-determinism-source.test.ts`, + `tests/runtime/policy-*.test.ts`, and Biome `biome-ignore` + comments with non-empty rationales. +- Verification commands stay the repo defaults from `package.json` and + `.ground-control.yaml`: `pnpm lint`, `pnpm typecheck`, `pnpm test`, + and the combined completion command. + +## Cross-Cutting Layers + +| Layer | Guardrail | +|-------|-----------| +| Source-policy comments | `// PUL-*-allow: ` comments are executable policy inputs. Preserve the exact allow tag, same-line placement, and non-empty reason unless replacing it with an equivalent valid exemption. | +| Lint suppressions | `// biome-ignore ...: ` comments are lint policy, not prose. Do not remove or generalize them while trimming architectural comments. | +| Scene/composition schemas | Comment edits must not introduce parallel DTO language such as `SceneDTO`, `CaptionSchema`, duplicate manifest shapes, or new validation tables. Refer to the canonical runtime types and validators. | +| URL and mode dispatch | Do not imply scenes own query parsing, mode flags, or history state. URL grammar remains `parseNavigationSearch()` and mode resolution remains `effectiveMode()` at the runtime/workbench boundary. | +| Timeline boundary | `src/runtime/timeline.ts` is the GSAP adapter and beat-label home. Comments should not describe a placeholder runner where `createGsapCompositionTimeline()` now owns the path. | +| Audio boundary | Audio remains `ctx.audio`, `createAudioService()`, and the Howler engine wrapper. Comments should distinguish Howler's engine behavior from the explicit present-mode unlock adapter in `audio-unlock-dom.ts`. | +| Validation boundary | Validation remains an orchestrator over existing runtime contracts. Do not document validation as a second registry, schema system, linter, or lifecycle runner. | +| Error envelope | Public diagnostics stay bounded through `describeErrorDetailed()` and `formatSceneContext()`. Do not change error messages or add comments that encourage parsing `Error.message` for structured data. | +| Security policy | The audit adds no auth surface, secret handling, cookies, local/session storage, `import.meta.env`, `process.env`, `process.argv`, remote dynamic import, `eval`, or generated code path. Existing source-policy gates remain authoritative. | +| OS/process exposure | No command should pass source snippets, tokens, headers, credentials, or env values through process argv. The audit is static source review plus repo-local tests. | +| Changelog policy | Docs-only preflight changes need no fragment. Source-comment-only changes are documentation/process-only unless they alter user-visible behavior or public surfaces; do not hand-edit `CHANGELOG.md`. | + +## Extensibility + +The only useful seam for future repeat audits is the search scope and +term set. Keep it parameterized as roots plus terms, not as a runtime +concept or comment taxonomy. If stale-comment enforcement becomes +recurring, the canonical place is a focused source-policy test that +reuses `tests/runtime/source-policy.ts`; do not add a separate scanner, +package script, config schema, or CI workflow for this issue. + +## Gotchas + +- `placeholder` is often an intentional scene id, tag, DOM marker, or + no-op adapter. Do not rename identifiers or remove local comments + just because the word appears. +- `future` can mean a deliberate extension seam, not stale work. + Examples include optional future URL parameters, future command + variants, and future renderer surfaces. Keep these when they explain + why the current boundary is shaped for extension. +- `not yet run` in fixtures can describe observed runtime state rather + than missing implementation. +- Some comments are known high-risk candidates because adjacent code + has moved: bootstrap text that still says a placeholder timeline + runner is waiting for ADR-003, prompter comments that predate the L2 + renderer wiring, and audio comments that say unlock is entirely + auto-handled by Howler. Verify against code before editing. +- ADR references inside source can drift when later ADRs supersede a + section. Prefer a short local invariant plus a single current ADR + reference over a historical chain. +- Broad rationale belongs in ADRs/design docs. Do not expand source + comments to compensate for deleting stale text. +- Removing a source-policy exemption rationale can break tests even + though runtime behavior is unchanged. + +## Anti-Patterns + +- Implementing a comment-audit runtime helper, enum, config file, lint + plugin, or custom script. +- Rewriting comments by requirement status alone without checking the + current source and accepted ADRs. +- Updating runtime behavior to make an old comment true. +- Changing public error strings, data attributes, URL grammar, command + kinds, validation findings, or policy allow tags as part of prose + cleanup. +- Duplicating scene, composition, caption, presenter, audio, asset, or + error schemas in comments. +- Adding changelog fragments for docs-only or source-comment-only + changes when no user-visible behavior changed. + +## Non-Goals + +Issue 100 does not add new architecture, requirements, ADR decisions, +workflow automation, source-policy enforcement, validation behavior, +runtime features, browser UI, telemetry, security policy, persistence, +or release tooling. + +It also does not require exhaustive prose normalization. The goal is to +remove or correct stale future-work language while preserving comments +that carry real local maintenance value. diff --git a/docs/design/issue-101-production-asset-policy-preflight.md b/docs/design/issue-101-production-asset-policy-preflight.md new file mode 100644 index 0000000..9ecca63 --- /dev/null +++ b/docs/design/issue-101-production-asset-policy-preflight.md @@ -0,0 +1,190 @@ +# Issue 101 Production Asset URL And Credential Policy Preflight + +Date: 2026-05-23 + +Issue 101 asks for a production-facing policy over the existing asset +preloader: URL schemes, `baseUrl`, relative assets, `data:` / `blob:`, +redirects, credentialed fetches, and cross-origin behavior. + +The runtime already has the right policy boundary. This issue should +document the production profile and, only if the documented profile is +claimed as runtime-enforced, extend the existing preloader and +validation policy hooks. It is not a new asset subsystem, scene schema, +configuration system, fetch wrapper, error hierarchy, logger, or +deployment workflow. + +## Boundary + +- `src/runtime/scene.ts` owns the asset inventory through + `SceneModule.assets`; production policy must not add a second asset + manifest or per-deployment scene DTO. +- `src/runtime/asset-preloader.ts` owns URL resolution, scheme + allowlisting, fetch/drain, redirect re-validation, and request-init + forwarding. Production enforcement belongs here when it is runtime + behavior. +- `src/runtime/validation.ts` mirrors the preloader's `baseUrl` and + `allowedSchemes` policy in `ValidationInput.assets`. Any new static + URL policy check that can run without network I/O must be mirrored + here so boot/CI validation and runtime preload agree. +- `src/main.ts` and future deployment/export entrypoints own the + deployment profile: they choose `baseUrl`, scheme allowlist, fetch, + and request init. Do not encode production policy in scene modules. +- ADR-012 remains the canonical asset-preloader decision. If the issue + changes accepted preloader semantics, update ADR-012 rather than + leaving the policy only in issue prose. + +## Required Reuse + +Implementation must build on these incumbents: + +- Asset inventory and schema: `SceneModule.assets`, + `SceneModule.audio`, `assertSceneModule()`, and the `scene.audio` + subset rule. +- Asset URL policy: `resolveAssetUrl()`, + `DEFAULT_ALLOWED_SCHEMES`, `AssetPreloaderOptions.baseUrl`, + `AssetPreloaderOptions.allowedSchemes`, + `AssetPreloaderOptions.fetch`, and `AssetPreloaderOptions.init`. +- Structural validation: `validateRuntime()`, + `ValidationInput.assets`, `Finding`, and + `assertNoValidationFindings()`. +- Lifecycle and cancellation: `createSceneLoader()`'s + `createPreloader(signal)` seam, `AssetPreloader`, + `resolveComposition()`, and the per-navigation `AbortSignal`. +- Error and diagnostics: `Error`, `AggregateError`, `Error.cause`, + `describeError()`, loader `onError`, + `data-pulsar-navigation-error`, and validation findings. +- Workflow and tests: `pnpm test`, `pnpm typecheck`, `pnpm lint`, + `tests/runtime/asset-preloader.test.ts`, + `tests/runtime/validation.test.ts`, + `tests/runtime/workbench-graph.test.ts`, and existing source-policy + tests. + +## Production Policy + +Recommended hardened production profile: + +- Require an explicit `baseUrl` at every production entrypoint that + preloads assets. This is what makes relative assets and + root-relative assets resolve to an absolute URL that the same scheme + policy can validate before fetch. Authoring/dev may omit `baseUrl` + for browser convenience; production should not. +- Use `allowedSchemes: ['https:']` for public production. `https:` is + cacheable, inspectable by normal deployment controls, and avoids + mixed-content and network-tampering risks. +- Treat `http:` as non-production except explicitly local or private + deployments that accept transport risk. Do not leave `http:` enabled + in the public production profile. +- Treat `data:` and `blob:` as authoring defaults, not hardened + production defaults. They bypass origin/CDN allowlisting, + deployment scanning, cache policy, and stable URL review. Allow them + only for a documented trusted-inline profile where scene metadata is + fully trusted and no credentialed fetch policy depends on origin. +- Never allow `file:`, `javascript:`, `ftp:`, `gopher:`, `ws:`, or + `wss:` in production asset preload. They either expose local host + resources, execute or imply code, bypass normal asset delivery, or + do not fit the byte-warming `fetch` contract. +- Reject protocol-relative URLs without `baseUrl`; the current + `resolveAssetUrl()` behavior already does this. With `baseUrl`, + validate the resolved absolute URL exactly like any other asset. +- Keep post-redirect validation mandatory. A declared allowed URL that + redirects to a disallowed scheme must fail after fetch and before + body drain. If origin allowlisting is added, re-check the final + redirected origin too. +- For third-party or CDN origins, prefer uncredentialed public assets. + Do not pass global `Authorization`, `Cookie`, token-bearing headers, + or `credentials: 'include'` to all asset URLs when any asset can + resolve to a third-party origin. +- Credentialed asset fetches are allowed only when credentials are + bound per destination URL. Use a custom `fetch` or a future + preloader policy seam that attaches credentials only for + same-origin or explicitly allowlisted origins and refuses every + other destination. + +This policy is stricter than the authoring default +`DEFAULT_ALLOWED_SCHEMES` (`http:`, `https:`, `data:`, `blob:`). Do +not change that default just to express a production deployment choice; +entrypoints must opt into the production profile. + +## Cross-Cutting Layers + +| Layer | Guardrail | +|-------|-----------| +| Scene schema gate | `assertSceneModule()` already guarantees `assets` is an array of strings and `audio` entries are members of `assets`. Do not add `ProductionAsset`, asset ids, or a second schema. | +| Workbench graph validation | Browser boot and CI validation should pass the same production asset policy to `validateRuntime()` when running a production profile. Do not let validation use authoring defaults while preload uses hardened settings. | +| URL parser / policy gate | Reuse `resolveAssetUrl()` for `baseUrl`, protocol-relative URLs, absolute URLs, and scheme allowlists. Do not copy URL parsing into `main.ts`, tests, scene modules, or docs examples. | +| Fetch boundary | `createAssetPreloader()` remains the only runtime fetch/drain path for declared scene assets. Keep `fetch` injection and `init.signal` forwarding; do not bypass it with ``, `Image()`, Howler, or scene-local fetches. | +| Redirect security | Preserve up-front scheme validation and post-redirect scheme validation. If origin checks are added, they must run both before fetch and against `response.url` after redirects. | +| Credential handling | `RequestInit.headers` and `credentials` apply to every fetch call unless a custom `fetch` gates them per URL. Production policy must not rely on authors remembering which absolute URLs receive shared credentials. | +| Cross-origin / CORS | Scheme allowlisting is not origin allowlisting. Third-party assets need valid CORS headers for `fetch` preload and must be treated as public unless an explicit credentialed-origin policy exists. | +| SSRF / private network | `https:` still permits loopback, link-local, RFC 1918, and metadata-service hosts. If production processes untrusted scene metadata, use deployment egress controls now or add a canonical origin/private-network policy seam; scheme checks alone are insufficient. | +| Audio source policy | Audio source validation also reuses `resolveAssetUrl()` and `DEFAULT_ALLOWED_SCHEMES`. Do not create a looser audio URL policy than the production asset policy; a future shared policy parameter should feed audio too. | +| Lifecycle / cancellation | Asset policy failures remain preload failures before `create(ctx)`. Abort-driven supersession remains an `AbortSignal` concern, not a policy error or scene lifecycle failure. | +| Error envelope | Diagnostics may name scene id, declared asset string, resolved URL when needed, scheme, origin, and rule. Do not dump request headers, cookies, authorization values, full response objects, stacks, env values, raw scene objects, DOM nodes, or serialized causes. | +| Config / env binding | Production profile values are non-secret configuration: base URL, allowed schemes, and optional origins. Secrets and credentials must stay out of scene metadata, URL query params, validation findings, docs examples, and committed config. | +| OS-level exposure | Do not pass credential-bearing asset URLs, headers, cookies, or tokens through process argv, shell commands, filenames, CI annotations, or artifacts. Tests should use injected fake fetches and in-memory policy objects. | +| Observability | Keep observability on existing surfaces: validation findings, `onError`, DOM diagnostic attributes, and bounded console errors. Do not add telemetry, localStorage/sessionStorage logs, or a parallel production logger. | +| Workflow | Source changes need a Towncrier fragment; this preflight doc and README entry do not. Ground Control has no requirement UID for this issue-driven run, so no requirement status transition or traceability link is available. | + +## Extensibility + +The required seam is the existing asset policy object shared by +preload and validation. Keep it parameterized at the entrypoint: +`baseUrl`, `allowedSchemes`, `fetch`, and `init` today. + +If production policy needs runtime enforcement beyond the current +behavior, extend this seam narrowly instead of creating a new +configuration layer. The obvious future parameters are: + +- `allowedOrigins` for first-party/CDN host control and private-network + defense that cannot be expressed by schemes. +- `credentialedOrigins` or an equivalent credential policy for the + destinations allowed to receive `Authorization`, cookies, or + `credentials: 'include'`. + +Any static part of those checks should be mirrored in +`ValidationInput.assets`; any fetch-time part must live in +`createAssetPreloader()` and must be re-applied after redirects. The +audio service should eventually consume the same policy rather than +keeping a separate default-only source check. + +## Gotchas And Anti-Patterns + +- Do not document `allowedSchemes: ['https:']` as sufficient SSRF + protection. It is scheme hardening, not host or network hardening. +- Do not require absolute asset strings in `scene.assets`. Production + strictness belongs in `baseUrl` plus policy options, not authoring + metadata. +- Do not silently rely on the browser's `document.baseURI` in + production; that prevents static scheme and origin validation of + relative assets. +- Do not send shared headers or cookies through `init` when third-party + assets are permitted. Use custom fetch or a canonical credential + policy. +- Do not put credential-bearing URLs in diagnostics, tests, examples, + shell commands, or CI config. +- Do not add validation that fetches assets or probes the network. + Structural validation stays side-effect-free. +- Do not special-case `data:` / `blob:` as "safe because no network." + They bypass deployment controls and should remain opt-in for trusted + profiles. +- Do not create `ProductionAssetPreloader`, `AssetPolicyValidator`, + `AssetLoadError`, or a parallel exception hierarchy when the + existing preloader, validator, and `AggregateError` path cover the + behavior. +- Do not weaken redirect validation or set `redirect: 'manual'` just + to avoid final-URL policy checks. If redirects are followed, the + final URL must satisfy the same policy. + +## Non-Goals + +Issue 101 does not require asset retries, fallback URLs, decode-complete +image/font/audio loading, CDN health checks, network existence checks, +service-worker preloading, cache warming policy, new workbench modes, +new URL parameters, scene recovery UI, telemetry, persistence, a new +logging system, or a deployment CLI. + +It should not change scene metadata shape, composition manifest shape, +registry behavior, navigation grammar, timeline orchestration, scene +cleanup semantics, ADR-028 scene failure isolation, or the byte-warming +scope of ADR-012 unless a separate decision explicitly changes those +contracts. diff --git a/docs/design/issue-102-scene-module-trust-boundary-preflight.md b/docs/design/issue-102-scene-module-trust-boundary-preflight.md new file mode 100644 index 0000000..1933232 --- /dev/null +++ b/docs/design/issue-102-scene-module-trust-boundary-preflight.md @@ -0,0 +1,139 @@ +# Issue 102 Scene Module Trust Boundary Preflight + +Date: 2026-05-23 + +Issue 102 is a documentation/security-boundary change. Scene modules +are executable application code. The implementation should document that +trust model where scene authors and future import/workflow designers will +see it, without changing runtime behavior or implying that current +validation makes untrusted code safe. + +This preflight is not the issue-closing trust-boundary documentation. It +is the repo-wide guardrail for that documentation. + +## Boundary + +- Pulsar scene modules are trusted, repo-owned application code loaded + through the authored bundle and registered scene/composition graph. +- The current runtime has no sandbox for scene lifecycle hooks. A scene's + `create(ctx)`, `timeline(ctx)`, and `cleanup(ctx)` execute with the + privileges of the application context that invoked them. +- Runtime validation checks declarative shape and metadata. It does not + inspect code safety, execute hooks safely, restrict browser APIs, or + prove that a module is trustworthy. +- The PUL-Q007 no-remote-code-execution policy prevents runtime string + execution and unsafe dynamic imports in authored source. It does not + transform bundled third-party scene code into untrusted-safe code. +- Asset policy governs declared URLs and fetch credentials. It is not a + code sandbox and must not be presented as one. +- User-submitted, third-party, plugin, marketplace, or shared-scene + execution is out of scope until a future requirement designs isolation. + +## Required Reuse + +Implementation guidance must build on these incumbents: + +- Scene contract: `SceneModule`, `SceneLifecycleFn`, + `assertSceneModule()`, and `sceneDeclaresAudio()` in + `src/runtime/scene.ts`. +- Structural validation: `validateRuntime()`, `ValidationInput`, + `Finding`, and `assertNoValidationFindings()` in + `src/runtime/validation.ts`. +- Registry and composition boundaries: + `createSceneRegistry()`, `createCompositionRegistry()`, + `assertCompositionManifest()`, and `resolveComposition()`. +- Lifecycle and context seams: `createSceneLoader()`, + `WorkbenchSceneCtx`, `SceneActivation`, `SceneFailureEvent`, + `create(ctx)`, `timeline(ctx)`, and `cleanup(ctx)`. +- Scene-facing capabilities exposed through context: `ctx.stage`, + `ctx.chrome`, `ctx.gsap`, `ctx.audio`, `ctx.presenter`, `ctx.mode`, + `ctx.rng`, and `ctx.activation`. +- Asset/security docs: `docs/asset-url-policy.md`, ADR-012, and + `docs/design/issue-101-production-asset-policy-preflight.md`. +- Source execution policy: `tests/runtime/policy-q007-remote-code-execution.test.ts` + and `docs/design/pul-q007-runtime-code-execution-preflight.md`. +- Error and diagnostic boundaries: `describeError()`, + `describeErrorDetailed()`, ADR-028, and the PUL-Q006 preflight. + +## Cross-Cutting Layers + +| Layer | Guardrail | +|-------|-----------| +| Source execution policy | Keep Q007's scope precise: no `eval`, `Function`, unsafe dynamic import, or remote code execution outside the bundle. Do not claim Q007 makes arbitrary bundled scene modules safe. | +| Scene schema gate | `assertSceneModule()` validates required fields, captions, ids, assets, audio membership, and lifecycle function presence. It is a shape gate only. Do not add `trusted`, `sandboxed`, `origin`, or `author` fields to `SceneModule` for this issue. | +| Runtime validation | `validateRuntime()` remains pure structural metadata validation. It must not execute lifecycle hooks, scan code trust, fetch assets, read files, or emit "safe to run" claims. | +| Registry/composition graph | Registries remain the static authored graph. Do not add plugin discovery, remote registry import, marketplace loading, or user-uploaded scene ingestion under this issue. | +| Lifecycle hooks | The docs must name the authority of `create(ctx)`, `timeline(ctx)`, and `cleanup(ctx)`: they can mutate mounted DOM through context, create timelines, use audio, subscribe to presenter state, and allocate resources that cleanup must release. Lifecycle failure isolation is reliability behavior, not security isolation. | +| Scene context | `WorkbenchSceneCtx` is the capability surface. Current scenes receive full application context for the active navigation. Do not document it as a reduced-permission or sandboxed capability set. | +| Asset/network policy | Declared assets and audio sources pass through `scene.assets`, `scene.audio`, `resolveAssetUrl()`, `createAssetPreloader()`, `baseUrl`, and `allowedSchemes`. These govern resource URLs and credentials, not arbitrary code behavior inside a trusted scene module. | +| Auth and secrets | The browser runtime should not require secrets for scene execution. Docs and examples must not put credentials in scene modules, asset URLs, validation findings, shell commands, or committed config. | +| Config/env/OS exposure | No env var, argv flag, browser storage toggle, or hidden config should mark third-party scene code as safe. Future isolation must be an explicit runtime/import architecture, not a deployment switch. | +| Error envelope | Diagnostics may name scene ids, lifecycle phases, asset strings, and policy names. Do not dump raw scene objects, captions, DOM, stacks, request headers, cookies, env, auth values, or serialized causes. | +| Observability/workflow | Existing docs, tests, source-policy scans, `pnpm test`, `pnpm typecheck`, and `pnpm lint` are enough. Do not add telemetry, SARIF, audit logs, or a separate security workflow for a documentation-only boundary. | +| Persistence | No persistence is required. Do not add trust allowlists in localStorage, files, caches, or committed registries. | + +## Intended Design + +The user-facing documentation should state the trust model in the same +vocabulary as the runtime: + +- scene modules execute as trusted application code; +- validation checks schema/metadata only, not code safety; +- third-party or user-submitted scene execution is not sandboxed by + default; +- lifecycle hooks and `WorkbenchSceneCtx` are the API surface through + which scene code affects the runtime; +- asset policy and no-remote-code-execution policy are related + guardrails with narrower scopes, not substitutes for sandboxing. + +Prefer one canonical trust-boundary section, cross-linked from related +docs, over scattered warnings. Do not bury the boundary only in a +preflight note or test comment. + +## Extensibility + +The future sandbox seam belongs outside the current scene schema. If a +future requirement accepts untrusted or third-party scene execution, it +must define a separate import/execution model and a capability-reduced +context at the runtime entrypoint that chooses which scene catalog is +loaded. + +That future design must be parameterized by execution profile and +granted capabilities, not by ad hoc per-scene booleans. It must also +cover module loading, DOM authority, network and asset policy, audio, +timelines, presenter/control surfaces, cleanup guarantees, diagnostics, +and host/browser isolation. Until then, the documented position is +"trusted application code only." + +## Gotchas And Anti-Patterns + +- Do not write "validated scene" as shorthand for "safe scene." +- Do not conflate "untrusted scene metadata" in the asset policy with + untrusted executable scene modules. +- Do not describe Q007 as a sandbox. It is a source-policy gate against + executing code outside the published bundle. +- Do not add a `trusted: true` or `sandboxed: false` field to every + scene. The trust boundary is a runtime/documentation invariant, not + per-scene metadata. +- Do not add plugin import, remote module loading, user upload, + marketplace, code-signing, CSP, iframe, worker, SES, or permission + systems as part of the documentation issue. +- Do not rely on lifecycle cleanup, scene failure isolation, or + `ctx.audio` group teardown as security containment. They are + reliability and resource-management contracts. +- Do not create a duplicate validator, exception hierarchy, source + scanner, logging surface, config loader, or policy file. +- Do not include a future-work promise unless the project accepts a + requirement or ADR for sandbox design. + +## Non-Goals + +Issue 102 does not implement a sandbox, plugin system, third-party scene +marketplace, upload workflow, import resolver, code signing, +permissions model, CSP generator, iframe/worker isolation, dependency +audit, telemetry, persistence, or a new CI gate. + +It should not change scene metadata shape, composition manifest shape, +registry behavior, URL grammar, workbench modes, asset preloading, +audio/timeline APIs, lifecycle ordering, validation categories, error +classification, logging, or package dependencies. diff --git a/docs/scene-trust-model.md b/docs/scene-trust-model.md new file mode 100644 index 0000000..62fcda5 --- /dev/null +++ b/docs/scene-trust-model.md @@ -0,0 +1,229 @@ +# Scene module trust model + +This is the canonical statement of how Pulsar treats the code inside +scene modules. The position the runtime takes is explicit: scene +modules are trusted, repo-owned application code. There is no sandbox +for scene execution today, and the existing structural gates do not +turn untrusted code into safe code. + +This doc exists so a future contributor adding scene import, plugin +loading, third-party sharing, marketplace flow, or any other surface +that brings in code Pulsar did not author cannot accidentally inherit +the current trust model. The current model only covers code in this +repository. + +## Scope + +- **Scene authors** writing modules under `src/scenes/**`. +- **Future workflow designers** considering import, upload, + marketplace, plugin, or third-party scene flows. +- **Security reviewers** checking what the runtime's structural + validation, source-policy gates, and asset URL policy do and do not + cover. + +Out of scope: asset URLs and credentials (see +[`docs/asset-url-policy.md`](asset-url-policy.md)); runtime-source +constructions that would execute code outside the published bundle +(see the PUL-Q007 entry under [Related guardrails](#related-guardrails)). + +## Trust position + +Scene modules are trusted, repo-owned application code, loaded through +the authored bundle and registered in the scene and composition +graphs ([ADR-002](adrs/002-scene-registry-and-compositions.md), +[ADR-008](adrs/008-agent-native-authoring.md)). + +A scene's lifecycle hooks — `create(ctx)`, `timeline(ctx)`, and +`cleanup(ctx)` — execute with the privileges of the application +context that invoked them. There is no capability-reduced execution +profile, no iframe or worker isolation, no permission gate, no +per-scene allowlist of browser APIs, no CSP boundary owned by the +runtime, and no code-signing or origin check on the scene module +itself. + +A scene module that lands in this repository can therefore reach any +browser API the workbench (or the test runner) exposes to its host +JavaScript context. The fact that a scene "passes validation" is a +statement about its declared shape and metadata only — it is not a +statement that the code inside its lifecycle hooks is safe to run. + +## What runtime validation does cover + +The runtime ships two structural gates over scene declarations. Both +are metadata-only. + +- `assertSceneModule(value)` in + [`src/runtime/scene.ts`](../src/runtime/scene.ts) validates the + `SceneModule` contract: id format and kebab-case grammar, title, + duration, tag/asset/audio/captions array shape, caption time and + text fields, `defaultNext` shape, `standalone` / `trailerSafe` + booleans, presence and type of `create`, `timeline`, and `cleanup`, + and the cross-field invariant that every audio entry is declared in + `assets`. It does not call any lifecycle hook, fetch anything, + inspect the function body of `create` / `timeline` / `cleanup`, or + evaluate code. +- `validateRuntime({ scenes, compositions, assets })` in + [`src/runtime/validation.ts`](../src/runtime/validation.ts) runs the + same shape check over a registry plus composition manifests, plus a + resolvable-asset check against the configured asset URL policy + (`baseUrl`, `allowedSchemes`). It collects findings instead of + failing fast. It is a pure orchestrator over the existing per-record + checks — it never executes lifecycle hooks, walks `import()` graphs, + fetches anything, touches the DOM, reads cookies or process state, + or makes "safe to run" claims about scene code. + +## What runtime validation does NOT cover + +- Code safety inside `create(ctx)`, `timeline(ctx)`, or + `cleanup(ctx)`. The lifecycle hooks are application-privileged + function bodies that the validation pass never executes. +- Reduction of the capability set available through `ctx` (see + [Capability surface](#capability-surface-lifecycle-and-context)). +- Containment of side effects from a misbehaving scene. The + composition resolver isolates scene *failures* for reliability + (see ADR-028 under [Related guardrails](#related-guardrails)), but + that is a reliability boundary, not a security boundary — a scene + that does not throw can still write to the DOM, schedule timers, + hold listeners, etc. +- Trust of any third-party, user-submitted, plugin, marketplace, or + remotely loaded scene module. The runtime has no such surface today + (see [Third-party / user-submitted scenes — non-goal](#third-party--user-submitted-scenes--non-goal)). + +The phrase "validated scene" in code review, comments, or docs means +"its declarative shape was checked." It does not mean "this scene is +safe to run." + +## Capability surface (lifecycle and context) + +Every active scene receives a `WorkbenchSceneCtx` (in +[`src/runtime/scene-loader.ts`](../src/runtime/scene-loader.ts)) +constructed by the loader per navigation. The full set of capabilities +a scene can reach the runtime through is: + +| Field | What it grants | +|-------|----------------| +| `ctx.stage` | The workbench stage DOM element (or `null` in Node tests). Scenes mount, mutate, and tear down DOM through this handle in `create(ctx)` and `cleanup(ctx)`. | +| `ctx.presenter` | The per-navigation `PresenterController` under `mode=present` (undefined under other modes). Scenes that subscribe to advance/pause commands use this seam; the controller auto-detaches on the navigation's `AbortSignal`. | +| `ctx.chrome` | Optional L2 chrome slot refs (title, brand, centerpiece, lower-third, tag, act-frame, flash). Scenes built from the L2 template library address chrome slots through this field rather than ambient `document` lookups. | +| `ctx.mode` | The effective workbench mode (`present` / `standalone` / `loop` / `paused` / `scrub` / `screenshot` / `prompter` / `rehearsal`). Read-only — scene code can branch on it but not change it. | +| `ctx.gsap` | The GSAP instance scenes build timelines with in `timeline(ctx)`. Scenes call `ctx.gsap.timeline()` rather than importing GSAP directly. | +| `ctx.audio` | The per-navigation `AudioService` (load / play / fade / stop / `stopGroup` / mute). Scoped to the navigation's `AbortSignal`; per-scene sound is unloaded on cleanup. | +| `ctx.rng` | A deterministic seeded random generator (one float per call). Scoped per occurrence — distinct activations of the same scene id get distinct streams. | +| `ctx.activation` | The per-occurrence identity `{ sceneId, entryIndex, occurrence }`. Lets a scene own its occurrence's DOM, listeners, and state without colliding with sibling occurrences. | + +The lifecycle hooks themselves: + +- `create(ctx)` — mount DOM, set up listeners, register audio, + allocate per-scene resources. Runs once per occurrence at scene + entry. Application-privileged. +- `timeline(ctx)` — return the scene's `gsap.timeline()`. Runs once + per occurrence after `create(ctx)`. Application-privileged. +- `cleanup(ctx)` — tear down everything `create(ctx)` and + `timeline(ctx)` allocated. Mandatory per PUL-P001 and + [ADR-008](adrs/008-agent-native-authoring.md) #10. Runs once per + occurrence at scene exit. Application-privileged. + +The fact that this surface is the whole capability set is significant +twice: scene authors know exactly which seams the runtime owns, and +any future sandbox would have to parameterize this surface (see +[Future sandbox seam](#future-sandbox-seam)). + +## Related guardrails (narrower scopes, not sandboxing) + +These existing gates each cover a slice of "what the runtime can +trust." None of them is a code sandbox; do not present any of them as +one. + +- **PUL-Q007 source policy** — + [`tests/runtime/policy-q007-remote-code-execution.test.ts`](../tests/runtime/policy-q007-remote-code-execution.test.ts) + bans `eval`, `new Function(...)`, `Function(...)`, and dynamic + `import(specifier)` whose specifier is a remote URL or non-static + expression in authored runtime source (`src/**/*.ts`). It catches + attempts to execute code that is not present in the published + bundle. It does not transform a bundled scene module into + untrusted-safe code, and it does not restrict what bundled scene + code can do once it runs. + See + [`docs/design/pul-q007-runtime-code-execution-preflight.md`](design/pul-q007-runtime-code-execution-preflight.md). +- **Asset URL and credential policy** — [`docs/asset-url-policy.md`](asset-url-policy.md) + plus the `AssetPreloaderOptions` seams in + [`src/runtime/asset-preloader.ts`](../src/runtime/asset-preloader.ts) + govern resource URLs (scheme allowlist, `baseUrl`, redirects) and + credential delivery (no global `Authorization`, per-origin custom + `fetch`). It is a URL and credential gate, not a code sandbox. A + scene that declares only allowed asset URLs is no more or less + trusted as executable code than one that doesn't. +- **Scene-level failure isolation** — + [ADR-028](adrs/028-scene-level-error-isolation.md) and the resolver + in [`src/runtime/composition-resolver.ts`](../src/runtime/composition-resolver.ts) + keep a thrown scene from halting the composition, attempt cleanup + for the failing scene, and surface a redacted diagnostic. That is a + *reliability* contract — the next scene gets to run — not a + *security* contract. A scene that does not throw is not contained + by it. + +## Third-party / user-submitted scenes — non-goal + +Pulsar does not currently accept third-party, user-submitted, plugin, +marketplace, remote-registry, or upload-flow scene modules. There is +no import resolver, no remote registry client, no plugin loader, no +upload endpoint, no code-signing flow, no CSP that the runtime owns, +no iframe/worker isolation that the runtime owns, and no per-scene +`trusted` / `sandboxed` / `origin` / `author` field on the +`SceneModule` contract. + +A change that adds any of those is not in scope for any current +requirement, and the existing structural gates do not silently extend +to cover them. Until a future requirement designs isolation, the +expected position is "we run only the scene catalog this repository +ships." + +## Future sandbox seam + +If a future requirement accepts untrusted or third-party scene +execution, the implementation must define a separate import and +execution model and a *capability-reduced* `ctx` at the runtime +entrypoint that chooses which scene catalog is loaded. That design +must be parameterized by an execution profile and the granted +capabilities — not by ad-hoc per-scene booleans on `SceneModule` and +not by a deployment toggle that flips arbitrary scene code from +"untrusted" to "trusted." + +A complete future sandbox would have to cover: module loading, DOM +authority, network and asset policy (which is already a separate +gate), audio, timelines, presenter and control surfaces, cleanup +guarantees, diagnostics redaction, and host or browser isolation +(iframe / worker / web sandbox / SES / similar). None of that exists +today, and the trust model documented above is the position the +runtime takes until such a design is accepted. + +The preflight note for issue #102 — +[`docs/design/issue-102-scene-module-trust-boundary-preflight.md`](design/issue-102-scene-module-trust-boundary-preflight.md) — +records the boundary in more detail and is the binding guardrail for +this doc. + +## Related + +- [`docs/asset-url-policy.md`](asset-url-policy.md) — asset URL and + credential policy (narrower scope). +- [`docs/design/issue-102-scene-module-trust-boundary-preflight.md`](design/issue-102-scene-module-trust-boundary-preflight.md) + — the design preflight that binds this doc. +- [`docs/design/pul-q007-runtime-code-execution-preflight.md`](design/pul-q007-runtime-code-execution-preflight.md) + — PUL-Q007 boundary. +- [`tests/runtime/policy-q007-remote-code-execution.test.ts`](../tests/runtime/policy-q007-remote-code-execution.test.ts) + — PUL-Q007 source-policy gate. +- [ADR-001](adrs/001-custom-experience-runtime.md) — the runtime owns + the scene/composition model rather than delegating to a slide + framework. +- [ADR-002](adrs/002-scene-registry-and-compositions.md) — scene and + composition contract. +- [ADR-008](adrs/008-agent-native-authoring.md) — scene contract and + mandatory cleanup invariant. +- [ADR-028](adrs/028-scene-level-error-isolation.md) — scene + lifecycle failure isolation (reliability, not security). +- [`src/runtime/scene.ts`](../src/runtime/scene.ts) — `SceneModule`, + `assertSceneModule()`. +- [`src/runtime/scene-loader.ts`](../src/runtime/scene-loader.ts) — + `WorkbenchSceneCtx` capability surface. +- [`src/runtime/validation.ts`](../src/runtime/validation.ts) — + `validateRuntime()`. diff --git a/src/main.ts b/src/main.ts index c058b2b..6344af9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,8 +10,9 @@ // scene/composition modules. Both registries are immutable after // construction (ADR-008 #2 "manifests over flow control"). // 3. Build a `SceneLoader` (PUL-F008) wired to those registries plus -// the lifecycle adapters (PUL-F005 asset preloader; placeholder -// timeline runner until ADR-003's GSAP runner lands). +// the lifecycle adapters: PUL-F005 asset preloader, the ADR-003 / +// PUL-F022 GSAP-backed composition timeline, the PUL-F024 / ADR-004 +// audio service, and the PUL-F030 / ADR-029 audio-unlock adapter. // 4. Subscribe to the parsed-target events PUL-F007's // `bootstrapNavigation` dispatches: `pulsar:navigate` carries a // parsed `NavigationTarget`, `pulsar:navigate-error` carries a @@ -144,16 +145,15 @@ const createPreloader = (signal: AbortSignal): ReturnType` parented to // `document.body` (above the chrome surface). The L2 transitions // library (cut / dissolve / hard-slam / hold-on-black / push) tweens @@ -229,20 +229,15 @@ const buildCtx = ( return { ...base, chrome: chromeSlots as unknown as Readonly> }; }; -// Prompter renderer placeholder (PUL-F019 / ADR-022). Under -// `mode=prompter` the loader bypasses the resolver lifecycle -// structurally — no preload, no `create`, no `timeline`, no -// `cleanup` — and hands a `PrompterScript` (captions aggregated -// from the addressed scene or composition slice) to this adapter. -// Until the captions/script UI surface lands, the placeholder -// produces no visible output; the structural visual-rendering -// suppression is delivered by the loader's lifecycle bypass, not by -// this adapter. -// -// Pulsar L2 renderer: paints the full prompter script (composition -// id + per-scene captions) into the workbench. Falls back to -// `document.body` if the chrome slot resolution returns null. The -// returned dispose callback removes the panel on next navigation. +// Prompter renderer (PUL-F019 / ADR-022). Under `mode=prompter` the +// loader bypasses the resolver lifecycle structurally — no preload, +// no `create`, no `timeline`, no `cleanup` — and hands a +// `PrompterScript` (captions aggregated from the addressed scene or +// composition slice) to this adapter. The L2 +// `createChromePrompterRenderer` paints the full script (composition +// id + per-scene captions) into the chrome lower-third slot, falling +// back to `document.body` when the slot is unavailable. The returned +// dispose callback removes the panel on the next navigation. const renderPrompter: PrompterRenderer = createChromePrompterRenderer( () => chromeSlots?.lowerThird ?? document.body, ); diff --git a/src/runtime/audio.ts b/src/runtime/audio.ts index 9d6de09..fcd4c46 100644 --- a/src/runtime/audio.ts +++ b/src/runtime/audio.ts @@ -441,8 +441,8 @@ export const AUDIO_OUTPUT_POLICIES = Object.freeze(['audible', 'silent', 'log-cu * {@link AudioServiceOptions.onCue} sink. The PUL-F026 / ADR-004 * rehearsal-mode contract: "audio is silenced OR logged as cues * without altering timeline state." With no sink wired the policy - * is effectively silent (the workbench has not yet attached a cue - * UI / log surface). + * is effectively silent (the workbench attaches no cue UI / log + * surface today). * * Future variations (silent rehearsal as a distinct mode, export * silence, ducking, bus volume, an audio-status UI) extend this same diff --git a/src/runtime/presenter.ts b/src/runtime/presenter.ts index de26242..028bc9b 100644 --- a/src/runtime/presenter.ts +++ b/src/runtime/presenter.ts @@ -309,8 +309,8 @@ export function createPresenterController( // Lazy source attachment: register the central wrapper on the // source the first time a subscriber attaches. A controller with - // zero subscribers (e.g., the placeholder timeline runner that - // ignores `input.presenter`) does not pay a source registration. + // zero subscribers (e.g., a timeline runner that ignores + // `input.presenter`) does not pay a source registration. // Subscribe-time failures from the workbench-supplied source are // routed through `onError` here rather than escaping the loader's // `buildLoad` (codex review, post-PUL-F025: a throwing source diff --git a/src/runtime/prompter.ts b/src/runtime/prompter.ts index 5533f6b..87f688b 100644 --- a/src/runtime/prompter.ts +++ b/src/runtime/prompter.ts @@ -3,8 +3,9 @@ // Pure function over a {@link SceneNavigationTarget} (already validated // by `resolveSceneNavigation` per PUL-F008 / ADR-014). Produces a // {@link PrompterScript} carrying the captions metadata of the -// addressed scene or composition slice. The future captions/script UI -// surface consumes this shape; the scene loader hands it to a +// addressed scene or composition slice. The captions/script UI surface +// (the L2 `createChromePrompterRenderer` the workbench bootstrap +// wires) consumes this shape; the scene loader hands it to a // {@link PrompterRenderer} adapter without interpreting it. // // Why prompter is structurally different from `mode=loop` / @@ -113,20 +114,19 @@ export type PrompterDispose = () => void | Promise; * obtain the dispose callback (if any), then proceeds. * * A renderer can ALSO use the older parking-until-abort pattern - * (return a `Promise` that resolves on `signal.aborted`, - * with cleanup in the abort listener), which the placeholder under - * `mode=present` uses for the timeline runner. Both patterns - * satisfy the contract; the dispose-return pattern is preferred - * for renderers that mount DOM because the loader OWNS the abort - * sequencing — there is no documentation-only "you must keep your - * promise pending" convention for the renderer to forget. + * (return a `Promise` that resolves on `signal.aborted`, with + * cleanup in the abort listener). Both patterns satisfy the contract; + * the dispose-return pattern is preferred for renderers that mount + * DOM because the loader OWNS the abort sequencing — there is no + * documentation-only "you must keep your promise pending" convention + * for the renderer to forget. * - * Optional on {@link import('./scene-loader').SceneLoaderOptions}: a - * workbench bootstrap that has not yet wired a captions UI omits the - * field and the loader dispatches `mode=prompter` without invoking any - * renderer (visual rendering is still structurally suppressed because - * the resolver lifecycle is bypassed). Production bootstrap supplies a - * concrete renderer when the UI surface lands. + * Optional on {@link import('./scene-loader').SceneLoaderOptions}: + * callers that don't render a captions view (test harnesses, + * embedders) omit the field and the loader dispatches `mode=prompter` + * without invoking any renderer (visual rendering is still + * structurally suppressed because the resolver lifecycle is bypassed). + * The workbench supplies the L2 `createChromePrompterRenderer`. */ // `void` in this union is intentional: the "no cleanup obligation" // half of the contract must accept implicit-return arrow functions diff --git a/src/runtime/scene-loader.ts b/src/runtime/scene-loader.ts index 2f96312..2f278a9 100644 --- a/src/runtime/scene-loader.ts +++ b/src/runtime/scene-loader.ts @@ -335,14 +335,13 @@ export interface SceneLoaderOptions { * cleanup. A trivial / no-DOM renderer (e.g. a test stub) is free * to return synchronously because there is nothing to tear down. * - * Optional: a workbench bootstrap that has not yet wired a captions - * UI omits the field. Under `mode=prompter` the loader still - * suppresses the resolver lifecycle (no preload, no `create`, no - * `timeline`, no `cleanup`) because that suppression is the - * structural defense PUL-F019 / ADR-022 record; the captions data - * path simply has no consumer until the UI lands. Production - * bootstrap supplies a concrete renderer when the captions/script - * UI surface lands. + * Optional: callers that don't render a captions view (test + * harnesses, embedders) omit the field. Under `mode=prompter` the + * loader still suppresses the resolver lifecycle (no preload, no + * `create`, no `timeline`, no `cleanup`) because that suppression + * is the structural defense PUL-F019 / ADR-022 record; the captions + * data path simply has no consumer in that configuration. The + * workbench supplies the L2 `createChromePrompterRenderer`. */ readonly renderPrompter?: PrompterRenderer; /** @@ -360,11 +359,11 @@ export interface SceneLoaderOptions { * subscribes via `input.presenter.subscribe(...)` and forgets to * unsubscribe cannot leak across navigations. * - * Optional: a workbench bootstrap that has not yet wired a - * presenter UI omits the field. Under that configuration the - * loader does not build a controller, runners see + * Optional: callers that don't supply presenter input (test + * harnesses, embedders, non-presenter contexts) omit the field. The + * loader then does not build a controller, runners see * `input.presenter === undefined`, and the seam is structurally - * inert until the workbench wires a real source. + * inert. */ readonly presenterCommands?: PresenterCommandSource; /** diff --git a/src/runtime/timeline.ts b/src/runtime/timeline.ts index 993dc6d..2d97f58 100644 --- a/src/runtime/timeline.ts +++ b/src/runtime/timeline.ts @@ -40,8 +40,8 @@ // resolves on the master's natural completion (so the resolver tears // every scene down) or on navigation abort. // -// Scenes that have not yet authored a timeline return `null` (the -// placeholder scene does this); the adapter composes such a scene as a +// Scenes that don't author a timeline return `null` (the placeholder +// scene does this); the adapter composes such a scene as a // zero-duration segment rather than rejecting it. // // References: @@ -114,8 +114,8 @@ const isGsapTimeline = (value: unknown): value is GsapTimeline => /** * Validate the value a scene's `timeline(ctx)` returned, including its - * beats. `null` / `undefined` are accepted as "no timeline authored - * yet" (the placeholder scene returns `null`); any other non-timeline + * beats. `null` / `undefined` are accepted as "no timeline authored" + * (the placeholder scene returns `null`); any other non-timeline * value is a scene-contract violation and throws * {@link SceneTimelineTypeError} with the scene id in the message. * @@ -1024,9 +1024,9 @@ function runMasterUntilDone( } /** - * Build the GSAP-backed {@link CompositionTimelineAdapter} the workbench - * wires onto the composition resolver (replacing the placeholder - * runner). Each `run(segments, opts)` call: composes `segments` (the + * Build the GSAP-backed {@link CompositionTimelineAdapter} the + * workbench wires onto the composition resolver. Each + * `run(segments, opts)` call: composes `segments` (the * active composition slice's scene timeline values, in manifest order) * into one master GSAP timeline; applies the head hints; reports the * live master to `onMaster`; then plays the master, resolving on its diff --git a/src/system/presenter/prompter-window.ts b/src/system/presenter/prompter-window.ts index c5b07c8..4016a7d 100644 --- a/src/system/presenter/prompter-window.ts +++ b/src/system/presenter/prompter-window.ts @@ -1,12 +1,12 @@ // Pulsar L2 — prompter window helper. // // `openPrompterWindow(url)` spawns a separate browser window booted -// in `mode=prompter`. The runtime's existing `renderPrompter` adapter -// in the spawned window receives the prompter script and renders it -// (the L2 system layer ships a default renderer below that upgrades -// the placeholder in `src/main.ts`). +// in `mode=prompter`. The runtime's `renderPrompter` adapter in the +// spawned window receives the prompter script and renders it (the L2 +// system layer ships `createChromePrompterRenderer` below, which the +// workbench bootstrap wires in `src/main.ts`). // -// Because pulsar already has end-to-end prompter wiring (loader-side +// Because pulsar has end-to-end prompter wiring (loader-side // lifecycle bypass + buildPrompterScript caption aggregation), the // prompter window just needs to open the same URL with mode=prompter. @@ -33,10 +33,9 @@ export const openPrompterWindow = ( }; /** - * A non-trivial `PrompterRenderer` that renders the full prompter - * script into the workbench chrome's lower-third slot (or a fallback - * `