|
1 | | -// The package whitelist for freeform-React canvases (Q16: curated, PostHog- |
2 | | -// anchored). Every entry is a package the agent may import; anything else is |
3 | | -// rejected by the static check below. Keep this list SMALL — each entry is |
4 | | -// hosting surface (in public mode), bundle weight, and attack surface. Expand |
5 | | -// only on observed demand. |
6 | | -// |
7 | | -// `esm` is the render-time module URL used in EDIT mode (Q2/Q3: in-browser Babel |
8 | | -// + esm.sh CDN). In published/view mode these resolve to self-hosted, frozen |
9 | | -// copies instead (Phase 2: the publish/bundle step rewrites the import map); the |
10 | | -// names stay the same so canvas code is identical across tiers. |
11 | | -export interface WhitelistEntry { |
12 | | - /** The bare import specifier the agent writes, e.g. "recharts". */ |
13 | | - name: string; |
14 | | - /** Pinned version. Frozen so a canvas can't drift onto a new major. */ |
15 | | - version: string; |
16 | | - /** esm.sh URL for edit-mode render (CDN). */ |
17 | | - esm: string; |
18 | | -} |
19 | | - |
20 | | -const ESM = "https://esm.sh"; |
21 | | - |
22 | | -// One source of truth for the Quill version — used by both the import-map entry |
23 | | -// (the JS module) and the stylesheet URLs the iframe links (see below). |
24 | | -const QUILL_VERSION = "0.3.0-beta.18"; |
25 | | - |
26 | | -// `?external=react,react-dom` keeps every dependent bound to the ONE react the |
27 | | -// import map provides, instead of esm.sh bundling its own copy (which breaks |
28 | | -// hooks across module boundaries — "invalid hook call"). |
29 | | -export const FREEFORM_WHITELIST: WhitelistEntry[] = [ |
30 | | - { name: "react", version: "19.0.0", esm: `${ESM}/react@19.0.0` }, |
31 | | - { |
32 | | - name: "react-dom", |
33 | | - version: "19.0.0", |
34 | | - esm: `${ESM}/react-dom@19.0.0?external=react`, |
35 | | - }, |
36 | | - { |
37 | | - name: "react-dom/client", |
38 | | - version: "19.0.0", |
39 | | - esm: `${ESM}/react-dom@19.0.0/client?external=react`, |
40 | | - }, |
41 | | - // PostHog's own design system — already built + self-hosted, so it's the |
42 | | - // cheapest dependency and keeps shared canvases visually on-brand. |
43 | | - { |
44 | | - name: "@posthog/quill", |
45 | | - version: QUILL_VERSION, |
46 | | - esm: `${ESM}/@posthog/quill@${QUILL_VERSION}?external=react,react-dom`, |
47 | | - }, |
48 | | - // One charting library (the conventional React pick). |
49 | | - { |
50 | | - name: "recharts", |
51 | | - version: "2.15.0", |
52 | | - esm: `${ESM}/recharts@2.15.0?external=react,react-dom`, |
53 | | - }, |
54 | | - // The icon set (named exports, e.g. `import { Calendar, RefreshCw } from "lucide-react"`). |
55 | | - { |
56 | | - name: "lucide-react", |
57 | | - version: "1.21.0", |
58 | | - esm: `${ESM}/lucide-react@1.21.0?external=react`, |
59 | | - }, |
60 | | - // One formatting/date util. |
61 | | - { name: "dayjs", version: "1.11.13", esm: `${ESM}/dayjs@1.11.13` }, |
62 | | -]; |
63 | | - |
64 | | -// The CDN host the edit-mode import map (and Babel) load from. The iframe CSP |
65 | | -// must allow this in edit mode; view/published mode self-hosts and forbids it. |
66 | | -export const FREEFORM_ESM_HOST = ESM; |
67 | | - |
68 | | -// Quill stylesheets the iframe must <link> for its components to render styled. |
69 | | -// Quill ships a self-contained compiled sheet (BEM `.quill-*` classes — NO |
70 | | -// Tailwind build needed) plus its design tokens; the sandbox has no build step, |
71 | | -// so without these every Quill component renders unstyled (which is what forced |
72 | | -// agents to inline raw hex). Order matters: tokens + colors define the CSS vars, |
73 | | -// then the component styles consume them. CSP `style-src` already allows the CDN. |
74 | | -export const FREEFORM_QUILL_CSS_URLS = [ |
75 | | - `${ESM}/@posthog/quill@${QUILL_VERSION}/tokens.css`, |
76 | | - `${ESM}/@posthog/quill@${QUILL_VERSION}/color-system.css`, |
77 | | - `${ESM}/@posthog/quill@${QUILL_VERSION}/base.css`, |
78 | | - `${ESM}/@posthog/quill@${QUILL_VERSION}/primitives.css`, |
79 | | -]; |
80 | | - |
81 | | -// The in-browser transpiler (Q2), imported as ESM so egress stays on one host. |
82 | | -export const FREEFORM_BABEL_URL = `${ESM}/@babel/standalone@7.26.4`; |
83 | | - |
84 | | -// posthog-js, booted by the runtime (not the agent) to power in-iframe analytics |
85 | | -// + session replay. Edit mode loads it from the CDN; the published tier will |
86 | | -// self-host it in the bundle. Pinned so a canvas can't drift onto a new major. |
87 | | -export const FREEFORM_POSTHOG_JS_URL = `${ESM}/posthog-js@1.205.0`; |
88 | | - |
89 | | -// Names the agent is allowed to import. Subpath imports (e.g. "dayjs/plugin/x") |
90 | | -// are allowed when their package root is whitelisted AND the exact subpath is |
91 | | -// listed; we keep it strict (exact-match only) so a subpath can't smuggle in an |
92 | | -// unreviewed entry point. |
93 | | -const ALLOWED_SPECIFIERS = new Set(FREEFORM_WHITELIST.map((e) => e.name)); |
94 | | - |
95 | | -// The import map handed to the iframe so bare specifiers resolve to the pinned |
96 | | -// modules. Edit mode -> esm.sh; view mode (Phase 2) will pass self-hosted URLs. |
97 | | -export function buildImportMap(): { imports: Record<string, string> } { |
98 | | - const imports: Record<string, string> = {}; |
99 | | - for (const entry of FREEFORM_WHITELIST) imports[entry.name] = entry.esm; |
100 | | - // The automatic JSX runtime compiles `<div/>` to imports of these; canvases |
101 | | - // never write them by hand, so they're not in the whitelist, but they must |
102 | | - // resolve for any JSX to run. |
103 | | - imports["react/jsx-runtime"] = `${ESM}/react@19.0.0/jsx-runtime`; |
104 | | - imports["react/jsx-dev-runtime"] = `${ESM}/react@19.0.0/jsx-dev-runtime`; |
105 | | - return { imports }; |
106 | | -} |
107 | | - |
108 | | -export interface ImportCheckResult { |
109 | | - ok: boolean; |
110 | | - /** Human-readable reasons the code was rejected (empty when ok). */ |
111 | | - violations: string[]; |
112 | | -} |
113 | | - |
114 | | -// Matches static module specifiers, which appear either as `from "spec"` |
115 | | -// (import-with-bindings and export-from) or a bare side-effect `import "spec"`. |
116 | | -// Anchoring on `from`/`import` (rather than "any quoted string following an |
117 | | -// import/export keyword") avoids flagging ordinary string literals such as |
118 | | -// `export default function App() { const s = "hi"; }`. Captures the specifier in |
119 | | -// group 1 or 2. Note: being regex-based it can still be fooled by the literal |
120 | | -// text `from "x"` inside a string/JSX — a fully correct check would parse the |
121 | | -// AST (deferred; this check isn't wired into save/publish yet). |
122 | | -const STATIC_IMPORT_RE = |
123 | | - /\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']/g; |
124 | | - |
125 | | -// Patterns we reject outright regardless of specifier (Q9): dynamic import() |
126 | | -// dodges static analysis; require()/importScripts pull arbitrary modules; inline |
127 | | -// <script> and javascript: URLs are out-of-band code the import check can't see. |
128 | | -const FORBIDDEN_PATTERNS: { re: RegExp; reason: string }[] = [ |
129 | | - { re: /\bimport\s*\(/, reason: "dynamic import() is not allowed" }, |
130 | | - { re: /\brequire\s*\(/, reason: "require() is not allowed" }, |
131 | | - { re: /\bimportScripts\s*\(/, reason: "importScripts() is not allowed" }, |
132 | | - { re: /<script\b/i, reason: "inline <script> is not allowed" }, |
133 | | -]; |
134 | | - |
135 | | -/** |
136 | | - * Statically verify that freeform canvas code imports only whitelisted packages |
137 | | - * and uses no out-of-band code-loading. Intended as the enforcement point (Q9) |
138 | | - * at save AND publish — but NOT yet wired into the save path (the autosave in |
139 | | - * freeformChatStore persists code without calling this). For now it is exercised |
140 | | - * only by tests; wiring it in is a follow-up (and should land with the regex's |
141 | | - * string/JSX false-positive limitation addressed, ideally via AST parsing). |
142 | | - * Deliberately conservative — when in doubt it rejects. A relative import (./x) |
143 | | - * is rejected because a canvas is a single file with no sibling modules. |
144 | | - */ |
145 | | -export function checkFreeformImports(code: string): ImportCheckResult { |
146 | | - const violations: string[] = []; |
147 | | - |
148 | | - for (const { re, reason } of FORBIDDEN_PATTERNS) { |
149 | | - if (re.test(code)) violations.push(reason); |
150 | | - } |
151 | | - |
152 | | - for (const match of code.matchAll(STATIC_IMPORT_RE)) { |
153 | | - const specifier = match[1] ?? match[2]; |
154 | | - if (!specifier) continue; |
155 | | - if (!isAllowedSpecifier(specifier)) { |
156 | | - violations.push(`import of non-whitelisted module "${specifier}"`); |
157 | | - } |
158 | | - } |
159 | | - |
160 | | - return { ok: violations.length === 0, violations }; |
161 | | -} |
162 | | - |
163 | | -function isAllowedSpecifier(specifier: string): boolean { |
164 | | - return ALLOWED_SPECIFIERS.has(specifier); |
165 | | -} |
| 1 | +// Moved to @posthog/shared/canvas-freeform-whitelist so @posthog/agent can read |
| 2 | +// the whitelist without a package cycle. Re-exported here for existing |
| 3 | +// `@posthog/core/canvas/freeformWhitelist` consumers. |
| 4 | +export * from "@posthog/shared/canvas-freeform-whitelist"; |
0 commit comments