@lumoai/cli 1.66.0 → 1.68.0

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.
@@ -30,12 +30,21 @@
30
30
  *
31
31
  * Layered on top of `sanitizeField`, never instead of it: callers strip
32
32
  * control characters first, then guard.
33
+ *
34
+ * LUM-791 — the signal patterns match a *normalized copy* of the text
35
+ * (`normalizeForMatching`) while every reported offset and excerpt comes from
36
+ * the original. Before that, one invisible character bought a full bypass:
37
+ * `ig<U+200B>nore all previous instructions` matched no imperative pattern,
38
+ * and a lone zero-width is deliberately below `WEAK_INVISIBLE_MIN_RUN`, so
39
+ * both defences missed it at once. Normalization is orthogonal to precision —
40
+ * it widens no pattern, it only stops padding characters from being a bypass.
33
41
  */
34
42
  Object.defineProperty(exports, "__esModule", { value: true });
35
- exports.INJECTION_SIGNAL_LABEL = exports.INJECTION_SIGNAL_KINDS = void 0;
43
+ exports.NORMALIZE_MAX_CHARS = exports.INJECTION_SIGNAL_LABEL = exports.INJECTION_SIGNAL_KINDS = void 0;
36
44
  exports.escapesWrapper = escapesWrapper;
37
45
  exports.countWrapperEscapes = countWrapperEscapes;
38
46
  exports.neutralizeWrapperEscapes = neutralizeWrapperEscapes;
47
+ exports.normalizeForMatching = normalizeForMatching;
39
48
  exports.detectInjectionSignals = detectInjectionSignals;
40
49
  exports.summarizeContentSignals = summarizeContentSignals;
41
50
  exports.isContentSignals = isContentSignals;
@@ -133,21 +142,30 @@ const IMPERATIVE_EN = [
133
142
  // run / execute the following command
134
143
  /\b(?:run|execute)\s+(?:the\s+following|this|these|the\s+below)\s+(?:commands?|scripts?|code|instructions?|steps?|payload)\b/i,
135
144
  ];
145
+ /**
146
+ * Chinese is normally written without spaces between words, so inserting one
147
+ * costs a human reader nothing and used to break a contiguous character class
148
+ * outright (`忽略 之前 的所有指令` → no match). Every ZH pattern therefore
149
+ * joins its parts with a bounded separator class instead of butting them
150
+ * together. `{0,3}` keeps the gap too small to bridge unrelated sentences.
151
+ */
152
+ const ZH_SEP = '[\\s、,,]{0,3}';
153
+ const zh = (...parts) => new RegExp(parts.join(ZH_SEP));
136
154
  const IMPERATIVE_ZH = [
137
155
  // 忽略 / 无视 … 之前 … 指令
138
- /(?:忽略|无视|忘记|忘掉|抛弃|放弃|覆盖)(?:掉)?(?:之前|以上|上面|先前|前面|上述|所有|全部|一切|原来|原先|系统)(?:的)?(?:所有|全部|一切)?(?:指令|指示|提示|规则|内容|说明|要求|设定|限制|约束)/,
156
+ zh('(?:忽略|无视|忘记|忘掉|抛弃|放弃|覆盖)', '(?:掉)?', '(?:之前|以上|上面|先前|前面|上述|所有|全部|一切|原来|原先|系统)', '(?:的)?', '(?:所有|全部|一切)?', '(?:指令|指示|提示|规则|内容|说明|要求|设定|限制|约束)'),
139
157
  // 你现在是 / 扮演 …
140
- /你现在(?:是|扮演|作为|变成|成为|将是)/,
158
+ zh('你现在', '(?:是|扮演|作为|变成|成为|将是)'),
141
159
  // 新指令:
142
- /新(?:的)?(?:系统)?(?:指令|指示|规则|任务)\s*[::]/,
143
- // 行首角色标签
160
+ zh('新', '(?:的)?', '(?:系统)?', '(?:指令|指示|规则|任务)', '[::]'),
161
+ // 行首角色标签 — anchored on the line, so no separator tolerance to add.
144
162
  /(?:^|\n)[ \t]*(?:系统|助手|开发者)[ \t]*[::]/,
145
163
  // 不要告诉用户
146
- /(?:不要|别|勿|不准|禁止|不能)(?:告诉|告知|透露给|通知|提醒)(?:任何)?(?:用户|人类|审核者|操作者)/,
164
+ zh('(?:不要|别|勿|不准|禁止|不能)', '(?:告诉|告知|透露给|通知|提醒)', '(?:任何)?', '(?:用户|人类|审核者|操作者)'),
147
165
  // 输出你的系统提示
148
- /(?:输出|显示|打印|泄露|重复|说出|复述|公开)(?:你的|你|系统的)?(?:系统提示|系统指令|初始指令|隐藏指令|提示词)/,
166
+ zh('(?:输出|显示|打印|泄露|重复|说出|复述|公开)', '(?:你的|你|系统的)?', '(?:系统提示|系统指令|初始指令|隐藏指令|提示词)'),
149
167
  // 执行以下命令
150
- /(?:执行|运行)(?:以下|下面|下列|这些|这个|如下|后面|该)(?:的)?(?:命令|指令|脚本|代码|操作|步骤)/,
168
+ zh('(?:执行|运行)', '(?:以下|下面|下列|这些|这个|如下|后面|该)', '(?:的)?', '(?:命令|指令|脚本|代码|操作|步骤)'),
151
169
  ];
152
170
  const IMPERATIVE_PATTERNS = [...IMPERATIVE_EN, ...IMPERATIVE_ZH];
153
171
  /** A comment whose body is a paragraph, or carries an imperative, is a signal. */
@@ -212,6 +230,117 @@ function* matchAll(re, text) {
212
230
  re.lastIndex++;
213
231
  }
214
232
  }
233
+ // ─── Matching-time normalization (LUM-791) ───────────────────────────────────
234
+ /**
235
+ * Hard cap on how many characters the normalizer will rewrite. Text longer
236
+ * than this is never truncated — the tail is carried through verbatim — but
237
+ * the rewriting work (and the offset table it builds) stays bounded, because
238
+ * every injection point runs this on its hot path.
239
+ */
240
+ exports.NORMALIZE_MAX_CHARS = 1_000_000;
241
+ /**
242
+ * Compatibility forms that are the same letter in another dress: full-width
243
+ * ASCII (U+FF01-FF60, U+FFE0-FFEE), Latin ligatures, circled / parenthesised
244
+ * letters and digits, and the mathematical alphanumerics. Ordinary CJK,
245
+ * emoji and half-width katakana sit outside these ranges and are left alone.
246
+ * Held as a source string so the escapes stay readable in review.
247
+ */
248
+ const COMPAT_RANGES = '\\u2460-\\u24FF\\uFB00-\\uFB06\\uFF01-\\uFF60\\uFFE0-\\uFFEE\\u{1D400}-\\u{1D7FF}';
249
+ /** A single cheap scan: is there anything in here to rewrite at all? */
250
+ const NEEDS_NORMALIZE = new RegExp(`\\p{Cf}|[^\\S\\n]{2,}|[^\\S\\n\\x20]|[${COMPAT_RANGES}]`, 'u');
251
+ /**
252
+ * The three rewrite classes, in precedence order:
253
+ *
254
+ * 1. Unicode format characters (Cf) — zero-width, bidi controls, unicode
255
+ * tags, soft hyphen, BOM. Dropped: none of them carry meaning to a reader,
256
+ * all of them break a literal match.
257
+ * 2. Horizontal whitespace runs — tabs, NBSP, the full-width space U+3000 —
258
+ * folded to one ASCII space. Newlines are deliberately NOT folded: the
259
+ * role-label patterns are anchored on the start of a line.
260
+ * 3. Compatibility forms (COMPAT_RANGES), folded with NFKC.
261
+ */
262
+ const REWRITE = new RegExp(`\\p{Cf}+|[^\\S\\n]+|[${COMPAT_RANGES}]+`, 'gu');
263
+ const ALL_FORMAT = /^\p{Cf}+$/u;
264
+ const ALL_HORIZONTAL_WS = /^[^\S\n]+$/;
265
+ function rewriteRun(run) {
266
+ if (ALL_FORMAT.test(run))
267
+ return '';
268
+ if (ALL_HORIZONTAL_WS.test(run))
269
+ return ' ';
270
+ return run.normalize('NFKC');
271
+ }
272
+ /**
273
+ * Build the copy the injection patterns are matched against, plus the offset
274
+ * table that maps every position in it back to the original text.
275
+ *
276
+ * Pure: no module state, no shared `lastIndex`, same input → same output.
277
+ * Never throws. Text that needs nothing takes a fast path and is returned
278
+ * as-is, which is the overwhelmingly common case on the hot path.
279
+ */
280
+ function normalizeForMatching(text) {
281
+ if (!text || !NEEDS_NORMALIZE.test(text)) {
282
+ return {
283
+ text,
284
+ changed: false,
285
+ toOriginalIndex: (i) => Math.min(Math.max(i, 0), text.length),
286
+ };
287
+ }
288
+ const limit = Math.min(text.length, exports.NORMALIZE_MAX_CHARS);
289
+ const head = text.slice(0, limit);
290
+ const pieces = [];
291
+ // Checkpoints: normalized offset ↔ original offset, recorded at each
292
+ // rewrite. Between two checkpoints the mapping is a constant shift, so the
293
+ // table costs O(number of rewrites), not O(length).
294
+ const normAt = [0];
295
+ const origAt = [0];
296
+ let norm = 0;
297
+ let copied = 0;
298
+ const re = new RegExp(REWRITE.source, REWRITE.flags);
299
+ let m;
300
+ while ((m = re.exec(head)) !== null) {
301
+ const run = m[0];
302
+ if (run.length === 0) {
303
+ re.lastIndex++;
304
+ continue;
305
+ }
306
+ const replacement = rewriteRun(run);
307
+ if (replacement === run)
308
+ continue;
309
+ pieces.push(head.slice(copied, m.index), replacement);
310
+ norm += m.index - copied + replacement.length;
311
+ copied = m.index + run.length;
312
+ if (normAt[normAt.length - 1] === norm)
313
+ origAt[origAt.length - 1] = copied;
314
+ else {
315
+ normAt.push(norm);
316
+ origAt.push(copied);
317
+ }
318
+ }
319
+ // Everything left: the rest of the head plus any tail beyond the cap.
320
+ pieces.push(text.slice(copied));
321
+ const out = pieces.join('');
322
+ return {
323
+ text: out,
324
+ changed: out !== text,
325
+ toOriginalIndex: (i) => {
326
+ if (i <= 0)
327
+ return 0;
328
+ let lo = 0;
329
+ let hi = normAt.length - 1;
330
+ while (lo < hi) {
331
+ const mid = (lo + hi + 1) >> 1;
332
+ if (normAt[mid] <= i)
333
+ lo = mid;
334
+ else
335
+ hi = mid - 1;
336
+ }
337
+ // A compatibility fold can be longer than its source, so clamp to the
338
+ // next checkpoint rather than running past it.
339
+ const ceiling = lo + 1 < origAt.length ? origAt[lo + 1] : text.length;
340
+ return Math.min(origAt[lo] + (i - normAt[lo]), ceiling, text.length);
341
+ },
342
+ };
343
+ }
215
344
  /**
216
345
  * Scan untrusted text for content shaped like an instruction to the model.
217
346
  * Pure; never throws; empty input yields no signals. Signals come back in
@@ -221,41 +350,47 @@ function detectInjectionSignals(text) {
221
350
  if (!text)
222
351
  return { signals: [] };
223
352
  const signals = [];
353
+ // Patterns run against the normalized copy; every offset and excerpt the
354
+ // caller sees is translated back to the original text (LUM-791).
355
+ const normalized = normalizeForMatching(text);
356
+ const scan = normalized.text;
357
+ const original = (start, end) => text.slice(normalized.toOriginalIndex(start), normalized.toOriginalIndex(end));
224
358
  // ① imperative directives (EN + ZH)
225
359
  for (const p of IMPERATIVE_PATTERNS) {
226
360
  const g = new RegExp(p.source, p.flags.includes('g') ? p.flags : `${p.flags}g`);
227
- for (const m of matchAll(g, text)) {
361
+ for (const m of matchAll(g, scan)) {
228
362
  signals.push({
229
363
  kind: 'IMPERATIVE',
230
- index: m.index,
231
- excerpt: excerpt(m[0]),
364
+ index: normalized.toOriginalIndex(m.index),
365
+ excerpt: excerpt(original(m.index, m.index + m[0].length)),
232
366
  });
233
367
  }
234
368
  }
235
369
  // ② text hidden in HTML / Markdown comments
236
- for (const m of matchAll(HTML_COMMENT, text)) {
237
- const body = m[1] ?? '';
238
- const dense = body.replace(/\s+/g, '');
239
- if (dense.length >= HIDDEN_COMMENT_MIN_CHARS || hasImperative(body)) {
240
- signals.push({
241
- kind: 'HIDDEN_COMMENT',
242
- index: m.index,
243
- excerpt: excerpt(body),
244
- });
245
- }
246
- }
247
- for (const m of matchAll(MARKDOWN_COMMENT, text)) {
248
- const body = m[1] ?? m[2] ?? m[3] ?? '';
249
- const dense = body.replace(/\s+/g, '');
250
- if (dense.length >= HIDDEN_COMMENT_MIN_CHARS || hasImperative(body)) {
370
+ // The body is *tested* on the normalized copy (so a zero-width inside the
371
+ // comment cannot hide its imperative) and *shown* from the original.
372
+ const comment = (re, groups) => {
373
+ const d = new RegExp(re.source, `${re.flags}d`);
374
+ for (const m of matchAll(d, scan)) {
375
+ const g = groups.find(i => m[i] !== undefined);
376
+ if (g === undefined)
377
+ continue;
378
+ const body = m[g] ?? '';
379
+ const dense = body.replace(/\s+/g, '');
380
+ if (dense.length < HIDDEN_COMMENT_MIN_CHARS && !hasImperative(body))
381
+ continue;
382
+ const span = m.indices?.[g];
251
383
  signals.push({
252
384
  kind: 'HIDDEN_COMMENT',
253
- index: m.index,
254
- excerpt: excerpt(body),
385
+ index: normalized.toOriginalIndex(m.index),
386
+ excerpt: excerpt(span ? original(span[0], span[1]) : body),
255
387
  });
256
388
  }
257
- }
258
- // ③ zero-width / bidi / tag characters
389
+ };
390
+ comment(HTML_COMMENT, [1]);
391
+ comment(MARKDOWN_COMMENT, [1, 2, 3]);
392
+ // ③ zero-width / bidi / tag characters — on the ORIGINAL text by
393
+ // definition: these are exactly the characters normalization removes.
259
394
  {
260
395
  const strong = [...matchAll(STRONG_INVISIBLE, text)];
261
396
  // A leading BOM is a file artefact, not a hidden character.
@@ -276,7 +411,8 @@ function detectInjectionSignals(text) {
276
411
  });
277
412
  }
278
413
  }
279
- // ④ long high-entropy base64 / hex runs
414
+ // ④ long high-entropy base64 / hex runs — on the ORIGINAL text: the length
415
+ // floor and the entropy floors are calibrated against raw runs.
280
416
  for (const m of matchAll(ENCODED_RUN, text)) {
281
417
  const run = m[0];
282
418
  const floor = HEX_ONLY.test(run) ? HEX_MIN_ENTROPY : BASE64_MIN_ENTROPY;
@@ -288,14 +424,15 @@ function detectInjectionSignals(text) {
288
424
  });
289
425
  }
290
426
  }
291
- // ⑤ javascript: / vbscript: / data:<mime> links
427
+ // ⑤ javascript: / vbscript: / data:<mime> links — also on the normalized
428
+ // copy, so `java<U+200B>script:` cannot slip past the `\s*` tolerance.
292
429
  for (const p of SCRIPT_LINK_PATTERNS) {
293
430
  const g = new RegExp(p.source, `${p.flags}g`);
294
- for (const m of matchAll(g, text)) {
431
+ for (const m of matchAll(g, scan)) {
295
432
  signals.push({
296
433
  kind: 'SCRIPT_LINK',
297
- index: m.index,
298
- excerpt: excerpt(m[0]),
434
+ index: normalized.toOriginalIndex(m.index),
435
+ excerpt: excerpt(original(m.index, m.index + m[0].length)),
299
436
  });
300
437
  }
301
438
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.66.0",
3
+ "version": "1.68.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",