@skrr-ai/cli 0.1.101 → 0.1.103

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.
Files changed (91) hide show
  1. package/dist/base-command.js +24 -0
  2. package/dist/commands/agent/message/send.js +6 -2
  3. package/dist/commands/agents/triggers/create.js +7 -17
  4. package/dist/commands/agents/triggers/update.js +41 -28
  5. package/dist/commands/commitments/control/apply.d.ts +15 -0
  6. package/dist/commands/commitments/control/apply.js +36 -0
  7. package/dist/commands/commitments/control/preview.d.ts +17 -0
  8. package/dist/commands/commitments/control/preview.js +43 -0
  9. package/dist/commands/commitments/control/show.d.ts +12 -0
  10. package/dist/commands/commitments/control/show.js +29 -0
  11. package/dist/commands/commitments/create.js +5 -2
  12. package/dist/commands/commitments/observation/probe.d.ts +15 -0
  13. package/dist/commands/commitments/observation/probe.js +46 -0
  14. package/dist/commands/commitments/observation/summary.d.ts +9 -0
  15. package/dist/commands/commitments/observation/summary.js +31 -0
  16. package/dist/commands/commitments/preflight.js +18 -1
  17. package/dist/commands/commitments/readiness/show.d.ts +12 -0
  18. package/dist/commands/commitments/readiness/show.js +29 -0
  19. package/dist/commands/commitments/readiness/verify.d.ts +13 -0
  20. package/dist/commands/commitments/readiness/verify.js +33 -0
  21. package/dist/commands/commitments/roster.d.ts +15 -0
  22. package/dist/commands/commitments/roster.js +39 -0
  23. package/dist/commands/commitments/show.js +1 -1
  24. package/dist/commands/commitments/status.d.ts +17 -0
  25. package/dist/commands/commitments/status.js +41 -0
  26. package/dist/commands/commitments/update.js +23 -9
  27. package/dist/commands/commitments/work-plan.d.ts +11 -0
  28. package/dist/commands/commitments/work-plan.js +36 -0
  29. package/dist/commands/compass/effective-policy.d.ts +12 -0
  30. package/dist/commands/compass/effective-policy.js +34 -0
  31. package/dist/commands/compass/update.d.ts +12 -0
  32. package/dist/commands/compass/update.js +27 -0
  33. package/dist/commands/daemon/logs.d.ts +39 -1
  34. package/dist/commands/daemon/logs.js +140 -13
  35. package/dist/commands/followups/checkin.js +46 -3
  36. package/dist/commands/machines/dedicated/exec.js +3 -1
  37. package/dist/commands/proposals/discard.d.ts +4 -0
  38. package/dist/commands/proposals/discard.js +3 -0
  39. package/dist/commands/proposals/keep.d.ts +4 -0
  40. package/dist/commands/proposals/keep.js +3 -0
  41. package/dist/commands/proposals/snooze.d.ts +4 -0
  42. package/dist/commands/proposals/snooze.js +3 -0
  43. package/dist/commands/spaces/workflows/cancel-node.d.ts +16 -0
  44. package/dist/commands/spaces/workflows/cancel-node.js +44 -0
  45. package/dist/commands/spaces/workflows/cancel.d.ts +40 -0
  46. package/dist/commands/spaces/workflows/cancel.js +109 -3
  47. package/dist/commands/spaces/workflows/run.d.ts +9 -0
  48. package/dist/commands/spaces/workflows/run.js +38 -0
  49. package/dist/commands/tasks/approvals/resolve.d.ts +12 -0
  50. package/dist/commands/tasks/approvals/resolve.js +21 -5
  51. package/dist/commands/tasks/schedule.js +1 -1
  52. package/dist/commands/triggers/capabilities.js +7 -0
  53. package/dist/commands/triggers/create.js +12 -18
  54. package/dist/commands/triggers/fire.js +3 -0
  55. package/dist/commands/triggers/update.js +41 -27
  56. package/dist/commands/widgets/check.d.ts +39 -0
  57. package/dist/commands/widgets/check.js +179 -0
  58. package/dist/lib/agent-mailbox.d.ts +0 -1
  59. package/dist/lib/agent-mailbox.js +0 -1
  60. package/dist/lib/commitment-loop-status.d.ts +16 -0
  61. package/dist/lib/commitment-loop-status.js +164 -0
  62. package/dist/lib/commitments.d.ts +47 -1
  63. package/dist/lib/commitments.js +218 -6
  64. package/dist/lib/compasses.d.ts +3 -0
  65. package/dist/lib/compasses.js +1 -0
  66. package/dist/lib/delegated-cli.d.ts +23 -1
  67. package/dist/lib/delegated-cli.js +61 -4
  68. package/dist/lib/first-party-harness.d.ts +1 -1
  69. package/dist/lib/first-party-harness.js +1 -1
  70. package/dist/lib/responsibility-controls.d.ts +11 -0
  71. package/dist/lib/responsibility-controls.js +119 -0
  72. package/dist/lib/session-task-write.d.ts +13 -0
  73. package/dist/lib/session-task-write.js +63 -3
  74. package/dist/lib/stewardship-output.js +13 -2
  75. package/dist/lib/triggers.d.ts +26 -0
  76. package/dist/lib/triggers.js +134 -1
  77. package/dist/lib/widgetCheck.d.ts +123 -0
  78. package/dist/lib/widgetCheck.js +998 -0
  79. package/dist/lib/widgetStaticScan.d.ts +111 -0
  80. package/dist/lib/widgetStaticScan.js +833 -0
  81. package/dist/lib/workflow-node-control.d.ts +10 -0
  82. package/dist/lib/workflow-node-control.js +76 -0
  83. package/dist/lib/workspaces.d.ts +2 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +9 -1
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +12 -0
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +9 -1
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +12 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  89. package/dist/node_modules/@skrr-ai/data-provider/index.js +11924 -8704
  90. package/oclif.manifest.json +1487 -396
  91. package/package.json +11 -2
@@ -0,0 +1,833 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.lexJs = lexJs;
4
+ exports.maskJsComments = maskJsComments;
5
+ exports.maskCssComments = maskCssComments;
6
+ exports.maskHtmlComments = maskHtmlComments;
7
+ exports.classifyUrl = classifyUrl;
8
+ exports.scanWidgetSource = scanWidgetSource;
9
+ const OPEN_LINK = 'window.skrr.openLink(url)';
10
+ // ── small text helpers ──────────────────────────────────────────────────────
11
+ /** ASCII-only lowercase: the same length as the input, so offsets stay valid. */
12
+ function asciiLower(value) {
13
+ let out = '';
14
+ let last = 0;
15
+ for (let i = 0; i < value.length; i += 1) {
16
+ const c = value.charCodeAt(i);
17
+ if (c >= 65 && c <= 90) {
18
+ out += value.slice(last, i) + String.fromCharCode(c + 32);
19
+ last = i + 1;
20
+ }
21
+ }
22
+ return last === 0 ? value : out + value.slice(last);
23
+ }
24
+ function lineStarts(text) {
25
+ const starts = [0];
26
+ let i = text.indexOf('\n');
27
+ while (i !== -1) {
28
+ starts.push(i + 1);
29
+ i = text.indexOf('\n', i + 1);
30
+ }
31
+ return starts;
32
+ }
33
+ function lineAt(starts, index) {
34
+ let lo = 0;
35
+ let hi = starts.length - 1;
36
+ while (lo < hi) {
37
+ const mid = (lo + hi + 1) >> 1;
38
+ if (starts[mid] <= index) {
39
+ lo = mid;
40
+ }
41
+ else {
42
+ hi = mid - 1;
43
+ }
44
+ }
45
+ return lo + 1;
46
+ }
47
+ function isSpace(c) {
48
+ return c === 32 || c === 9 || c === 10 || c === 13 || c === 12;
49
+ }
50
+ function isAsciiLetter(c) {
51
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
52
+ }
53
+ // ── comment masking ─────────────────────────────────────────────────────────
54
+ /** Replace `from..to` with spaces, keeping line breaks so line numbers stay valid. */
55
+ function blank(chars, from, to) {
56
+ for (let k = from; k < to; k += 1) {
57
+ if (chars[k] !== '\n') {
58
+ chars[k] = ' ';
59
+ }
60
+ }
61
+ }
62
+ const REGEX_PRECEDING_WORDS = new Set([
63
+ 'return',
64
+ 'typeof',
65
+ 'instanceof',
66
+ 'case',
67
+ 'in',
68
+ 'of',
69
+ 'delete',
70
+ 'void',
71
+ 'throw',
72
+ 'new',
73
+ 'else',
74
+ 'do',
75
+ 'yield',
76
+ 'await',
77
+ ]);
78
+ const isIdentChar = (c) => /[A-Za-z0-9_$]/.test(c) || c > '\u007f';
79
+ /**
80
+ * Read a script once. Total and linear: it never throws, every loop advances, and
81
+ * a misread costs a missed or an extra finding, never a hang. It is a heuristic
82
+ * where JavaScript is ambiguous (a `/` after `)` is read as division; an unclosed
83
+ * quote ends at the line break), and picks the reading that leaves the rest of the
84
+ * text lexable.
85
+ */
86
+ function lexJs(src) {
87
+ const n = src.length;
88
+ const code = src.split('');
89
+ const commentless = src.split('');
90
+ const literals = [];
91
+ const templateDepths = [];
92
+ let depth = 0;
93
+ let regexAllowed = true;
94
+ /** A `/` scan that reached the end of its line without closing is not repeated on that line. */
95
+ let regexBlockedUntil = -1;
96
+ let i = 0;
97
+ const keep = (kind, open, start, end) => {
98
+ literals.push({ kind, open, start, value: src.slice(start, end) });
99
+ blank(code, start, end);
100
+ };
101
+ /** Template text from `from` to a closing backtick or a `${`; returns the next offset to read. */
102
+ const readTemplate = (from, open) => {
103
+ let j = from;
104
+ while (j < n) {
105
+ const c = src.charAt(j);
106
+ if (c === '\\') {
107
+ j += 2;
108
+ }
109
+ else if (c === '`') {
110
+ keep('template', open, from, j);
111
+ regexAllowed = false;
112
+ return j + 1;
113
+ }
114
+ else if (c === '$' && src.charAt(j + 1) === '{') {
115
+ keep('template', open, from, j);
116
+ templateDepths.push(depth);
117
+ depth += 1;
118
+ regexAllowed = true;
119
+ return j + 2;
120
+ }
121
+ else {
122
+ j += 1;
123
+ }
124
+ }
125
+ keep('template', open, from, n);
126
+ return n;
127
+ };
128
+ while (i < n) {
129
+ const c = src.charAt(i);
130
+ const d = src.charAt(i + 1);
131
+ if (c === '/' && d === '/') {
132
+ const end = src.indexOf('\n', i);
133
+ const to = end === -1 ? n : end;
134
+ blank(code, i, to);
135
+ blank(commentless, i, to);
136
+ i = to;
137
+ }
138
+ else if (c === '/' && d === '*') {
139
+ const end = src.indexOf('*/', i + 2);
140
+ const to = end === -1 ? n : end + 2;
141
+ blank(code, i, to);
142
+ blank(commentless, i, to);
143
+ i = to;
144
+ }
145
+ else if (c === '"' || c === "'") {
146
+ let j = i + 1;
147
+ while (j < n && src.charAt(j) !== c && src.charAt(j) !== '\n') {
148
+ j += src.charAt(j) === '\\' ? 2 : 1;
149
+ }
150
+ keep('string', i, i + 1, Math.min(j, n));
151
+ i = j < n && src.charAt(j) === c ? j + 1 : Math.min(j, n);
152
+ regexAllowed = false;
153
+ }
154
+ else if (c === '`') {
155
+ i = readTemplate(i + 1, i);
156
+ }
157
+ else if (c === '}' &&
158
+ templateDepths.length > 0 &&
159
+ templateDepths[templateDepths.length - 1] === depth - 1) {
160
+ templateDepths.pop();
161
+ depth -= 1;
162
+ i = readTemplate(i + 1, i);
163
+ }
164
+ else if (c === '/') {
165
+ let closed = -1;
166
+ if (regexAllowed && i > regexBlockedUntil) {
167
+ let j = i + 1;
168
+ let inClass = false;
169
+ while (j < n && src.charAt(j) !== '\n') {
170
+ const e = src.charAt(j);
171
+ if (e === '\\') {
172
+ j += 2;
173
+ continue;
174
+ }
175
+ if (e === '[') {
176
+ inClass = true;
177
+ }
178
+ else if (e === ']') {
179
+ inClass = false;
180
+ }
181
+ else if (e === '/' && !inClass) {
182
+ closed = j;
183
+ break;
184
+ }
185
+ j += 1;
186
+ }
187
+ if (closed === -1) {
188
+ regexBlockedUntil = Math.min(j, n);
189
+ }
190
+ }
191
+ if (closed !== -1) {
192
+ keep('regex', i, i + 1, closed);
193
+ i = closed + 1;
194
+ while (i < n && /[a-z]/i.test(src.charAt(i))) {
195
+ i += 1;
196
+ }
197
+ regexAllowed = false;
198
+ }
199
+ else {
200
+ i += 1;
201
+ regexAllowed = true;
202
+ }
203
+ }
204
+ else if (/[A-Za-z_$]/.test(c) || c > '\u007f') {
205
+ let j = i + 1;
206
+ while (j < n && isIdentChar(src.charAt(j))) {
207
+ j += 1;
208
+ }
209
+ regexAllowed = REGEX_PRECEDING_WORDS.has(src.slice(i, j));
210
+ i = j;
211
+ }
212
+ else if (c >= '0' && c <= '9') {
213
+ let j = i + 1;
214
+ while (j < n && /[0-9A-Za-z_.]/.test(src.charAt(j))) {
215
+ j += 1;
216
+ }
217
+ regexAllowed = false;
218
+ i = j;
219
+ }
220
+ else {
221
+ // Whitespace says nothing about whether a `/` divides or starts a regular expression.
222
+ if (!isSpace(src.charCodeAt(i))) {
223
+ if (c === '{') {
224
+ depth += 1;
225
+ }
226
+ else if (c === '}') {
227
+ depth -= 1;
228
+ }
229
+ regexAllowed = !(c === ')' || c === ']' || c === '}');
230
+ }
231
+ i += 1;
232
+ }
233
+ }
234
+ return { code: code.join(''), commentless: commentless.join(''), literals };
235
+ }
236
+ /**
237
+ * Blank the comments of JavaScript, leaving strings, template text and regular
238
+ * expressions untouched (the URLs a script uses live in strings). Strings end at
239
+ * a line break, so one unbalanced quote cannot swallow the rest of the file; the
240
+ * cost of a misread is a missed finding, never a false one.
241
+ */
242
+ function maskJsComments(src) {
243
+ return lexJs(src).commentless;
244
+ }
245
+ /** Blank the block comments of CSS, stepping over quoted strings. */
246
+ function maskCssComments(src) {
247
+ const chars = src.split('');
248
+ const n = src.length;
249
+ let i = 0;
250
+ while (i < n) {
251
+ const c = src.charAt(i);
252
+ if (c === '/' && src.charAt(i + 1) === '*') {
253
+ const end = src.indexOf('*/', i + 2);
254
+ const to = end === -1 ? n : end + 2;
255
+ blank(chars, i, to);
256
+ i = to;
257
+ }
258
+ else if (c === '"' || c === "'") {
259
+ i += 1;
260
+ while (i < n && src.charAt(i) !== c && src.charAt(i) !== '\n') {
261
+ i += src.charAt(i) === '\\' ? 2 : 1;
262
+ }
263
+ i += 1;
264
+ }
265
+ else {
266
+ i += 1;
267
+ }
268
+ }
269
+ return chars.join('');
270
+ }
271
+ /** Blank `<!-- … -->` comments of HTML. */
272
+ function maskHtmlComments(src) {
273
+ const chars = src.split('');
274
+ let i = src.indexOf('<!--');
275
+ while (i !== -1) {
276
+ const end = src.indexOf('-->', i + 4);
277
+ const to = end === -1 ? src.length : end + 3;
278
+ blank(chars, i, to);
279
+ i = src.indexOf('<!--', to);
280
+ }
281
+ return chars.join('');
282
+ }
283
+ /** A URL the frame cannot fetch from, a self-contained one, or one with no base to resolve against. */
284
+ function classifyUrl(raw) {
285
+ const value = raw.trim();
286
+ if (value === '') {
287
+ return 'empty';
288
+ }
289
+ const lower = asciiLower(value);
290
+ if (lower.startsWith('data:') ||
291
+ lower.startsWith('blob:') ||
292
+ lower.startsWith('#') ||
293
+ lower.startsWith('javascript:') ||
294
+ lower.startsWith('about:') ||
295
+ lower.startsWith('mailto:') ||
296
+ lower.startsWith('tel:')) {
297
+ return 'inline';
298
+ }
299
+ if (lower.startsWith('//') || /^[a-z][a-z0-9+.-]*:/.test(lower)) {
300
+ return 'external';
301
+ }
302
+ return 'relative';
303
+ }
304
+ /** XML namespace identifiers look like URLs and load nothing. */
305
+ function isNamespaceUrl(url) {
306
+ return /^https?:\/\/www\.w3\.org\//i.test(url);
307
+ }
308
+ // ── collector ───────────────────────────────────────────────────────────────
309
+ class Collector {
310
+ section;
311
+ findings = [];
312
+ constructor(section) {
313
+ this.section = section;
314
+ }
315
+ add(severity, code, line, message, fix) {
316
+ this.findings.push({
317
+ severity,
318
+ code,
319
+ section: this.section,
320
+ line,
321
+ message,
322
+ ...(fix ? { fix } : {}),
323
+ });
324
+ }
325
+ }
326
+ // ── CSS ─────────────────────────────────────────────────────────────────────
327
+ function scanCssInto(source, baseLine, out) {
328
+ const masked = maskCssComments(source);
329
+ const lower = asciiLower(masked);
330
+ const starts = lineStarts(source);
331
+ const lineOf = (index) => baseLine + lineAt(starts, index) - 1;
332
+ let i = lower.indexOf('@import');
333
+ while (i !== -1) {
334
+ const end = lower.indexOf(';', i);
335
+ const statement = masked.slice(i, end === -1 ? undefined : end);
336
+ const target = /@import\s+(?:url\(\s*)?['"]?([^'")\s;]+)/i.exec(statement);
337
+ const kind = target ? classifyUrl(target[1]) : 'relative';
338
+ if (kind === 'external') {
339
+ out.add('error', 'external-resource', lineOf(i), '@import of a URL: the frame allows no stylesheet from a URL, so the rules never load.', 'Write the rules inline in this section; there are no web fonts or remote stylesheets.');
340
+ }
341
+ else if (kind !== 'inline') {
342
+ out.add('warn', 'relative-url', lineOf(i), '@import has nothing to resolve against inside the frame, so it loads nothing.', 'Write the rules inline.');
343
+ }
344
+ i = lower.indexOf('@import', i + 7);
345
+ }
346
+ let u = lower.indexOf('url(');
347
+ while (u !== -1) {
348
+ const close = masked.indexOf(')', u + 4);
349
+ const inner = masked.slice(u + 4, close === -1 ? undefined : close).trim();
350
+ const value = inner.replace(/^['"]|['"]$/g, '');
351
+ const kind = classifyUrl(value);
352
+ if (kind === 'external') {
353
+ out.add('error', 'external-resource', lineOf(u), 'url() of an external address: img-src and font-src allow only data: and blob: URLs here, so it is blocked.', 'Use inline SVG, a gradient, or a data: URI.');
354
+ }
355
+ else if (kind === 'relative') {
356
+ out.add('warn', 'relative-url', lineOf(u), 'url() of a relative path has nothing to resolve against inside the frame.', 'Use a data: URI or inline SVG.');
357
+ }
358
+ u = lower.indexOf('url(', u + 4);
359
+ }
360
+ if (lower.includes('</style')) {
361
+ out.add('warn', 'element-close', lineOf(lower.indexOf('</style')), 'The text contains a closing style tag, which can end the stylesheet early depending on how the frame embeds it.', 'Remove it from the CSS.');
362
+ }
363
+ }
364
+ const NETWORK_FIX = 'Compute the data in the script or inline it in the markup; the frame cannot send or fetch anything.';
365
+ /** `location` as the frame's own, never a variable or a property that happens to be called that. */
366
+ const FRAME_LOCATION = String.raw `(?:\b(?:window|document|self|top|parent)\s*\.\s*|(?<![\w$.]))(?<!\b(?:const|let|var)\s+)location`;
367
+ const JS_RULES = [
368
+ {
369
+ pattern: /\bfetch\s*\(/g,
370
+ severity: 'error',
371
+ code: 'network-api',
372
+ message: 'Looks like a fetch() call: connect-src is none, so the request is blocked.',
373
+ fix: NETWORK_FIX,
374
+ },
375
+ {
376
+ pattern: /\bXMLHttpRequest\b/g,
377
+ severity: 'error',
378
+ code: 'network-api',
379
+ message: 'XMLHttpRequest: connect-src is none, so the request is blocked.',
380
+ fix: NETWORK_FIX,
381
+ },
382
+ {
383
+ pattern: /\b(?:WebSocket|EventSource)\b/g,
384
+ severity: 'error',
385
+ code: 'network-api',
386
+ message: 'WebSocket or EventSource: connect-src is none, so the connection is blocked.',
387
+ fix: NETWORK_FIX,
388
+ },
389
+ {
390
+ pattern: /\bsendBeacon\b/g,
391
+ severity: 'error',
392
+ code: 'network-api',
393
+ message: 'navigator.sendBeacon: connect-src is none, so the beacon is blocked.',
394
+ fix: NETWORK_FIX,
395
+ },
396
+ {
397
+ pattern: /\bnew\s+(?:Shared)?Worker\b|\bimportScripts\s*\(|\bserviceWorker\b/g,
398
+ severity: 'error',
399
+ code: 'worker',
400
+ message: 'Worker, importScripts or serviceWorker: worker-src is none, so none can start.',
401
+ fix: 'Run the work on the main thread, in small steps (requestAnimationFrame or setTimeout).',
402
+ },
403
+ {
404
+ pattern: /\bRTCPeerConnection\b/g,
405
+ severity: 'warn',
406
+ code: 'webrtc',
407
+ message: 'RTCPeerConnection: widgets have no network access and this is not a supported channel.',
408
+ fix: 'Remove it.',
409
+ },
410
+ {
411
+ pattern: /\beval\s*\(/g,
412
+ severity: 'error',
413
+ code: 'dynamic-code',
414
+ message: 'eval(): the frame has no unsafe-eval, so it throws.',
415
+ fix: 'Parse data with JSON.parse and write the logic as ordinary functions.',
416
+ },
417
+ {
418
+ pattern: /\bnew\s+Function\b|(?<![.\w$])Function\s*\(/g,
419
+ severity: 'error',
420
+ code: 'dynamic-code',
421
+ message: 'new Function(): the frame has no unsafe-eval, so it throws.',
422
+ fix: 'Write the logic as ordinary functions.',
423
+ },
424
+ {
425
+ pattern: /\b(?:setTimeout|setInterval)\s*\(\s*['"`]/g,
426
+ severity: 'error',
427
+ code: 'dynamic-code',
428
+ message: 'setTimeout or setInterval with a string is eval in disguise: it throws here.',
429
+ fix: 'Pass a function instead of a string.',
430
+ },
431
+ {
432
+ pattern: /\b(?:localStorage|sessionStorage|indexedDB)\b|\bdocument\s*\.\s*cookie\b/g,
433
+ severity: 'error',
434
+ code: 'storage',
435
+ message: 'Storage or cookies: the frame has an opaque origin, so access throws SecurityError (unless the script catches it).',
436
+ fix: 'Keep state in variables; nothing persists after a reload.',
437
+ },
438
+ {
439
+ pattern: /\b(?:window|self|globalThis)\s*\.\s*(?:parent|top|opener)\s*\.\s*(?!postMessage\b)[A-Za-z_$]/g,
440
+ severity: 'error',
441
+ code: 'host-access',
442
+ message: 'Reaches into the page that hosts the frame: that is cross-origin, so it throws.',
443
+ fix: 'Use window.skrr (requestPrompt, openLink, navigate) to talk to the app.',
444
+ },
445
+ {
446
+ pattern: /\b(?:window|self|globalThis)\s*\.\s*(?:parent|top|opener)\b/g,
447
+ severity: 'warn',
448
+ code: 'host-access',
449
+ message: 'References the hosting page: the host ignores messages that do not come over its bridge.',
450
+ fix: 'Use window.skrr (requestPrompt, openLink, navigate).',
451
+ },
452
+ {
453
+ pattern: /(?<![.\w$])(?:top|parent)\s*\./g,
454
+ severity: 'warn',
455
+ code: 'host-access',
456
+ message: 'top. or parent. as a bare name: if this is the browsing context, it is cross-origin and throws; ignore if it is your own variable.',
457
+ fix: 'Use window.skrr (requestPrompt, openLink, navigate) to talk to the app.',
458
+ },
459
+ {
460
+ pattern: /\b(?:window|self|globalThis)\s*\.\s*open\s*\(/g,
461
+ severity: 'error',
462
+ code: 'popup',
463
+ message: 'window.open(): the sandbox has no allow-popups, so nothing opens.',
464
+ fix: `Use ${OPEN_LINK} for an https link.`,
465
+ },
466
+ {
467
+ // `location` as the frame's own: reached through window/document/self/top/parent, or
468
+ // bare. NOT a declaration (`const location = {...}` is a local that shares the name)
469
+ // and not a member of something else (`p.location = x`).
470
+ pattern: new RegExp(`${FRAME_LOCATION}\\s*(?:\\.\\s*href\\s*)?=(?!=)|${FRAME_LOCATION}\\s*\\.\\s*(?:assign|replace|reload)\\s*\\(`, 'g'),
471
+ severity: 'error',
472
+ code: 'navigation',
473
+ message: 'Navigates the frame itself: the host allows one load and removes the widget on the second.',
474
+ fix: `Use ${OPEN_LINK} to open a link, or window.skrr.navigate(path) for an in-app path.`,
475
+ },
476
+ ];
477
+ /** What a string SAYS, as opposed to what the code does: a URL at the start of one, or one inside markup it holds. */
478
+ const URL_IN_STRING = /(?:^|['"`])\s*(https?:\/\/[^\s'"`]+|\/\/[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+(?:\/[^\s'"`]*)?)/g;
479
+ const URL_START = /^\s*(?:https?:|\/\/)/;
480
+ /** Rules over the TEXT of string and template literals (never over code, where none of this can occur). */
481
+ const STRING_RULES = [
482
+ {
483
+ pattern: /\b(?:src|srcset|poster|data|background)\s*=\s*\\?['"]\s*(?:https?:|\/\/)/g,
484
+ message: 'Markup in a string loads a resource from a URL, which the frame blocks.',
485
+ fix: 'Use inline SVG, canvas, or a data: URI.',
486
+ },
487
+ {
488
+ pattern: /\burl\(\s*['"]?\s*(?:https?:|\/\/)/g,
489
+ message: 'url() of an external address in a string: the frame blocks it.',
490
+ fix: 'Use a gradient, inline SVG or a data: URI.',
491
+ },
492
+ ];
493
+ const STRING_ARGUMENT_RULES = [
494
+ {
495
+ lead: /\bimport\s*\(\s*['"`]/g,
496
+ message: 'import() of a URL: script-src allows only inline code and the platform libraries.',
497
+ fix: "Import a library by its bare name, for example await import('d3'), inside an async function.",
498
+ },
499
+ {
500
+ lead: /\.\s*(?:src|srcset|poster)\s*=\s*['"`]/g,
501
+ message: 'Sets src to a URL: img-src and script-src allow only data: and blob: here.',
502
+ fix: 'Use inline SVG, canvas, or a data: URI.',
503
+ },
504
+ ];
505
+ function scanJsInto(source, baseLine, out) {
506
+ const { code, commentless, literals } = lexJs(source);
507
+ const starts = lineStarts(source);
508
+ const lineOf = (index) => baseLine + lineAt(starts, index) - 1;
509
+ const codeLines = code.split('\n');
510
+ const textual = literals.filter((literal) => literal.kind !== 'regex');
511
+ const byOpen = new Map(textual.map((literal) => [literal.open, literal]));
512
+ // What the code does: names and calls, over the code view, where a word inside a
513
+ // string, a template's text or a regular expression is not a call.
514
+ for (const rule of JS_RULES) {
515
+ for (const match of code.matchAll(rule.pattern)) {
516
+ out.add(rule.severity, rule.code, lineOf(match.index ?? 0), rule.message, rule.fix);
517
+ }
518
+ }
519
+ // A call or assignment whose argument is a literal that names a URL.
520
+ for (const rule of STRING_ARGUMENT_RULES) {
521
+ for (const match of code.matchAll(rule.lead)) {
522
+ const open = (match.index ?? 0) + match[0].length - 1;
523
+ const literal = byOpen.get(open);
524
+ if (literal && URL_START.test(literal.value)) {
525
+ out.add('error', 'external-resource', lineOf(match.index ?? 0), rule.message, rule.fix);
526
+ }
527
+ }
528
+ }
529
+ // What a string says.
530
+ for (const literal of textual) {
531
+ for (const rule of STRING_RULES) {
532
+ for (const match of literal.value.matchAll(rule.pattern)) {
533
+ out.add('error', 'external-resource', lineOf(literal.start + (match.index ?? 0)), rule.message, rule.fix);
534
+ }
535
+ }
536
+ for (const match of literal.value.matchAll(URL_IN_STRING)) {
537
+ const url = match[1];
538
+ const index = literal.start + (match.index ?? 0);
539
+ const text = codeLines[lineAt(starts, index) - 1] ?? '';
540
+ if (isNamespaceUrl(url) || /\bopenLink\b/.test(text)) {
541
+ continue;
542
+ }
543
+ out.add('warn', 'external-url', lineOf(index), `A URL string (${url.length > 60 ? `${url.slice(0, 57)}...` : url}): the frame can load nothing from a URL.`, `If it is a link, pass it to ${OPEN_LINK}; if it is data or an image, inline it.`);
544
+ }
545
+ }
546
+ // The one rule that reads the text as the HTML parser would, and so reads strings too.
547
+ if (/<\/script/i.test(commentless)) {
548
+ const at = asciiLower(commentless).indexOf('</script');
549
+ out.add('warn', 'element-close', lineOf(at), 'The text contains a closing script tag, which can end the script early depending on how the frame embeds it.', 'Write it as <\\/script> inside a string.');
550
+ }
551
+ }
552
+ const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'textarea', 'title']);
553
+ function attr(tag, name) {
554
+ for (const a of tag.attrs) {
555
+ if (a.name === name) {
556
+ return a.value;
557
+ }
558
+ }
559
+ return undefined;
560
+ }
561
+ /** Walk the markup with indexOf: tags, their attributes, and the text of raw-text elements. */
562
+ function walkHtml(html) {
563
+ const lower = asciiLower(html);
564
+ const tags = [];
565
+ const raws = [];
566
+ const n = html.length;
567
+ let i = 0;
568
+ while (i < n) {
569
+ const lt = html.indexOf('<', i);
570
+ if (lt === -1) {
571
+ break;
572
+ }
573
+ const next = html.charCodeAt(lt + 1);
574
+ if (html.startsWith('<!--', lt)) {
575
+ const end = html.indexOf('-->', lt + 4);
576
+ i = end === -1 ? n : end + 3;
577
+ continue;
578
+ }
579
+ if (next === 33 || next === 63 || next === 47) {
580
+ const end = html.indexOf('>', lt + 2);
581
+ i = end === -1 ? n : end + 1;
582
+ continue;
583
+ }
584
+ if (!isAsciiLetter(next)) {
585
+ i = lt + 1;
586
+ continue;
587
+ }
588
+ let p = lt + 1;
589
+ while (p < n && !isSpace(html.charCodeAt(p)) && html[p] !== '>' && html[p] !== '/') {
590
+ p += 1;
591
+ }
592
+ const tag = { name: lower.slice(lt + 1, p), index: lt, attrs: [] };
593
+ while (p < n) {
594
+ while (p < n && (isSpace(html.charCodeAt(p)) || html[p] === '/')) {
595
+ p += 1;
596
+ }
597
+ if (p >= n) {
598
+ break;
599
+ }
600
+ if (html[p] === '>') {
601
+ p += 1;
602
+ break;
603
+ }
604
+ const nameStart = p;
605
+ while (p < n && !isSpace(html.charCodeAt(p)) && !'=>/'.includes(html.charAt(p))) {
606
+ p += 1;
607
+ }
608
+ const name = lower.slice(nameStart, p);
609
+ while (p < n && isSpace(html.charCodeAt(p))) {
610
+ p += 1;
611
+ }
612
+ let value = '';
613
+ if (html[p] === '=') {
614
+ p += 1;
615
+ while (p < n && isSpace(html.charCodeAt(p))) {
616
+ p += 1;
617
+ }
618
+ const quote = html.charAt(p);
619
+ if (quote === '"' || quote === "'") {
620
+ const end = html.indexOf(quote, p + 1);
621
+ value = html.slice(p + 1, end === -1 ? n : end);
622
+ p = end === -1 ? n : end + 1;
623
+ }
624
+ else {
625
+ const valueStart = p;
626
+ while (p < n && !isSpace(html.charCodeAt(p)) && html[p] !== '>') {
627
+ p += 1;
628
+ }
629
+ value = html.slice(valueStart, p);
630
+ }
631
+ }
632
+ if (name) {
633
+ tag.attrs.push({ name, value });
634
+ }
635
+ else if (p === nameStart) {
636
+ p += 1;
637
+ }
638
+ }
639
+ tags.push(tag);
640
+ i = p;
641
+ if (RAW_TEXT_ELEMENTS.has(tag.name)) {
642
+ const close = lower.indexOf(`</${tag.name}`, p);
643
+ const end = close === -1 ? n : close;
644
+ raws.push({ tag, content: html.slice(p, end), contentIndex: p });
645
+ i = end;
646
+ }
647
+ }
648
+ return { tags, raws };
649
+ }
650
+ const RESOURCE_ATTRIBUTES = ['src', 'srcset', 'poster', 'data', 'background', 'xlink:href', 'href'];
651
+ /** The first URL of each `srcset` candidate. */
652
+ function srcsetUrls(value) {
653
+ return value
654
+ .split(',')
655
+ .map((candidate) => candidate.trim().split(/\s+/)[0])
656
+ .filter(Boolean);
657
+ }
658
+ const STYLESHEET_LIKE_REL = /\b(?:stylesheet|preload|modulepreload|prefetch|icon|manifest)\b/i;
659
+ function scanTag(tag, line, out) {
660
+ const name = tag.name;
661
+ if (name === 'base') {
662
+ out.add('error', 'base-element', line, 'The base element is blocked: base-uri is none in the frame.', 'Remove it; use absolute data: URIs or none at all.');
663
+ return;
664
+ }
665
+ if (name === 'meta') {
666
+ const equiv = asciiLower(attr(tag, 'http-equiv') ?? '').trim();
667
+ if (equiv === 'refresh') {
668
+ out.add('error', 'navigation', line, 'A meta refresh navigates the frame itself: the host allows one load and removes the widget on the second.', 'Remove it; update the page with script instead.');
669
+ }
670
+ return;
671
+ }
672
+ if (name === 'form') {
673
+ const action = attr(tag, 'action');
674
+ if (action !== undefined && action.trim() !== '') {
675
+ out.add('error', 'form-action', line, 'A form with an action: form-action is none in the frame, so submitting is blocked.', 'Drop the action and handle the input and change events in script.');
676
+ }
677
+ }
678
+ if (name === 'object' || name === 'embed' || name === 'applet') {
679
+ out.add('error', 'plugin-element', line, `<${name}>: object-src is none in the frame, so it does not load.`, 'Use inline SVG, canvas or markup.');
680
+ return;
681
+ }
682
+ if (name === 'iframe' || name === 'frame') {
683
+ const src = attr(tag, 'src') ?? '';
684
+ const external = classifyUrl(src) === 'external';
685
+ out.add(external ? 'error' : 'warn', 'nested-frame', line, external
686
+ ? `<${name}> of a URL: frame-src is none in the frame, so it is blocked.`
687
+ : `<${name}>: frame-src is none in the frame; nested frames are not supported.`, 'Render the content directly in this widget.');
688
+ return;
689
+ }
690
+ const formaction = attr(tag, 'formaction');
691
+ if (formaction !== undefined && formaction.trim() !== '') {
692
+ out.add('error', 'form-action', line, 'formaction: form-action is none in the frame, so submitting is blocked.', 'Handle the click in script.');
693
+ }
694
+ if (name === 'a' || name === 'area') {
695
+ const href = attr(tag, 'href');
696
+ if (href !== undefined && classifyUrl(href) === 'external') {
697
+ out.add('warn', 'external-link', line, 'A link to a URL: following it would navigate the frame itself, and the host removes the widget on that second load.', `Use a button that calls ${OPEN_LINK}.`);
698
+ }
699
+ const target = asciiLower(attr(tag, 'target') ?? '').trim();
700
+ if (target === '_blank' || target === '_top' || target === '_parent') {
701
+ out.add('warn', 'popup', line, `target="${target}": the sandbox has no allow-popups or top navigation, so nothing opens.`, `Use ${OPEN_LINK}.`);
702
+ }
703
+ return;
704
+ }
705
+ const rel = attr(tag, 'rel') ?? '';
706
+ for (const attribute of RESOURCE_ATTRIBUTES) {
707
+ const value = attr(tag, attribute);
708
+ if (value === undefined) {
709
+ continue;
710
+ }
711
+ if (attribute === 'href' && name === 'link' && !STYLESHEET_LIKE_REL.test(rel)) {
712
+ continue;
713
+ }
714
+ if (attribute === 'data' && name !== 'object') {
715
+ continue;
716
+ }
717
+ for (const url of attribute === 'srcset' ? srcsetUrls(value) : [value]) {
718
+ const kind = classifyUrl(url);
719
+ if (kind === 'external' && !isNamespaceUrl(url)) {
720
+ out.add('error', 'external-resource', line, `<${name} ${attribute}> loads from a URL: the frame allows no script, stylesheet, image or font from a URL.`, 'Inline it: markup, inline SVG, canvas, or a data: URI. Libraries come only by bare name through a dynamic import.');
721
+ }
722
+ else if (kind === 'relative' && name !== 'a' && attribute !== 'href') {
723
+ out.add('warn', 'relative-url', line, `<${name} ${attribute}> is a relative path, which has nothing to resolve against inside the frame.`, 'Inline it or use a data: URI.');
724
+ }
725
+ }
726
+ }
727
+ }
728
+ function isClassicScript(tag) {
729
+ const type = asciiLower(attr(tag, 'type') ?? '')
730
+ .trim()
731
+ .split(';')[0];
732
+ return (type === '' ||
733
+ type === 'text/javascript' ||
734
+ type === 'application/javascript' ||
735
+ type === 'text/ecmascript' ||
736
+ type === 'application/ecmascript');
737
+ }
738
+ function isScannableScript(tag) {
739
+ return isClassicScript(tag) || asciiLower(attr(tag, 'type') ?? '').trim() === 'module';
740
+ }
741
+ function scanHtmlInto(html, out) {
742
+ const masked = maskHtmlComments(html);
743
+ const starts = lineStarts(html);
744
+ const { tags, raws } = walkHtml(masked);
745
+ const inlineScripts = [];
746
+ for (const tag of tags) {
747
+ scanTag(tag, lineAt(starts, tag.index), out);
748
+ const style = attr(tag, 'style');
749
+ if (style) {
750
+ scanCssInto(style, lineAt(starts, tag.index), out);
751
+ }
752
+ }
753
+ for (const raw of raws) {
754
+ const line = lineAt(starts, raw.contentIndex);
755
+ if (raw.tag.name === 'style') {
756
+ scanCssInto(raw.content, line, out);
757
+ }
758
+ else if (raw.tag.name === 'script' && isScannableScript(raw.tag)) {
759
+ scanJsInto(raw.content, line, out);
760
+ if (isClassicScript(raw.tag) && raw.content.trim() !== '') {
761
+ inlineScripts.push({ content: raw.content, line });
762
+ }
763
+ }
764
+ }
765
+ return inlineScripts;
766
+ }
767
+ // ── entry point ─────────────────────────────────────────────────────────────
768
+ /** Findings per (section, code) before the rest collapse into one line. */
769
+ const MAX_PER_CODE = 5;
770
+ const SEVERITY_RANK = { error: 2, warn: 1 };
771
+ function tidy(findings) {
772
+ // One finding per (section, line, code), the most severe kept: two rules that
773
+ // recognise the same call must not report it twice.
774
+ const best = new Map();
775
+ for (const f of findings) {
776
+ const key = `${f.section}:${f.line}:${f.code}`;
777
+ const held = best.get(key);
778
+ if (!held || SEVERITY_RANK[f.severity] > SEVERITY_RANK[held.severity]) {
779
+ best.set(key, f);
780
+ }
781
+ }
782
+ // A generic URL warning on a line that already has the specific error is noise.
783
+ const resourceLines = new Set([...best.values()]
784
+ .filter((f) => f.code === 'external-resource')
785
+ .map((f) => `${f.section}:${f.line}`));
786
+ const kept = [...best.values()].filter((f) => !(f.code === 'external-url' && resourceLines.has(`${f.section}:${f.line}`)));
787
+ kept.sort((a, b) => a.section.localeCompare(b.section) || a.line - b.line);
788
+ const seen = new Map();
789
+ const out = [];
790
+ const dropped = new Map();
791
+ for (const f of kept) {
792
+ const key = `${f.section}:${f.code}`;
793
+ const count = (seen.get(key) ?? 0) + 1;
794
+ seen.set(key, count);
795
+ if (count <= MAX_PER_CODE) {
796
+ out.push(f);
797
+ }
798
+ else {
799
+ const entry = dropped.get(key);
800
+ if (entry) {
801
+ entry.count += 1;
802
+ if (SEVERITY_RANK[f.severity] > SEVERITY_RANK[entry.severity]) {
803
+ entry.severity = f.severity;
804
+ }
805
+ }
806
+ else {
807
+ dropped.set(key, { ...f, count: 1 });
808
+ }
809
+ }
810
+ }
811
+ for (const entry of dropped.values()) {
812
+ const { count, ...rest } = entry;
813
+ out.push({ ...rest, message: `...and ${count} more of the same kind in ${rest.section}.` });
814
+ }
815
+ return out;
816
+ }
817
+ /**
818
+ * Scan one widget's three sections. Pure and total: it never throws and reads
819
+ * only the strings it is given; the cost is linear in their length, which the
820
+ * caller has already bounded by the widget limits.
821
+ */
822
+ function scanWidgetSource(source) {
823
+ const css = new Collector('css');
824
+ const html = new Collector('html');
825
+ const js = new Collector('js');
826
+ scanCssInto(source.css, 1, css);
827
+ const inlineScripts = scanHtmlInto(source.html, html);
828
+ scanJsInto(source.js, 1, js);
829
+ return {
830
+ findings: tidy([...css.findings, ...html.findings, ...js.findings]),
831
+ inlineScripts,
832
+ };
833
+ }