@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.
- package/assets/skill/SKILL.md +10 -3
- package/assets/skill/references/confirmation.md +3 -2
- package/assets/skill/references/criteria.md +107 -0
- package/assets/skill/references/pr-scan.md +129 -0
- package/assets/skill/references/verify.md +3 -3
- package/dist/cli/src/commands/pr-scan.js +224 -0
- package/dist/cli/src/commands/task-criteria-confirm.js +99 -0
- package/dist/cli/src/commands/task-status.js +84 -89
- package/dist/cli/src/commands/verify.js +4 -1
- package/dist/cli/src/index.js +18 -0
- package/dist/cli/src/lib/scan-format.js +84 -0
- package/dist/lib/acceptance/unmet-display.js +28 -0
- package/dist/shared/src/hunt-audit.js +83 -0
- package/dist/shared/src/untrusted-content.js +172 -35
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
}
|