@lumoai/cli 1.66.0 → 1.69.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.
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatScanLayerTail = formatScanLayerTail;
4
+ exports.formatHuntCoverageLine = formatHuntCoverageLine;
5
+ const sanitize_1 = require("./sanitize");
6
+ const security_scan_1 = require("../../../shared/src/security-scan");
7
+ const EXTERNAL_ERROR_TAIL = 200;
8
+ function tail(s, max) {
9
+ return s.length > max ? `…${s.slice(-max)}` : s;
10
+ }
11
+ /**
12
+ * ` · Secrets: checked · Code scan: checked (2 external findings) ·
13
+ * Dependencies: checked (1 dependency finding, 1 already on main) ·
14
+ * AI review: checked · Exploit paths: checked · partial · 3 left the queue
15
+ * (2 resolved on main, 1 merged unreviewed) — <scrubbed reason>`, or `''`
16
+ * when the scan carries no stage at all. The caller prefixes its own head
17
+ * (` PR #945 · scan CLEAN` / `Scan abc1234 · FINDINGS`).
18
+ *
19
+ * Stage segments only for keys present, in secrets/external/supplyChain/
20
+ * judge/hunt order; the `(N external findings)` count only decorates the
21
+ * Code scan segment, the dependency count (with its already-on-main share)
22
+ * only the Dependencies segment, and each only when N > 0.
23
+ */
24
+ function formatScanLayerTail(s) {
25
+ const segments = [];
26
+ for (const key of security_scan_1.SCAN_STAGE_KEYS) {
27
+ const state = s.stages[key];
28
+ if (!state)
29
+ continue;
30
+ const head = `${(0, security_scan_1.scanStageLabel)(key)}: ${(0, security_scan_1.scanStageStateLabel)(state)}`;
31
+ if (key === 'external' && s.externalFindings > 0) {
32
+ const noun = s.externalFindings === 1 ? 'finding' : 'findings';
33
+ segments.push(`${head} (${s.externalFindings} external ${noun})`);
34
+ }
35
+ else if (key === 'supplyChain' && (s.dependencyFindings ?? 0) > 0) {
36
+ const n = s.dependencyFindings ?? 0;
37
+ const noun = n === 1 ? 'finding' : 'findings';
38
+ const persisting = s.persistingFindings ?? 0;
39
+ const tail = persisting > 0 ? `, ${persisting} already on main` : '';
40
+ segments.push(`${head} (${n} dependency ${noun}${tail})`);
41
+ }
42
+ else {
43
+ segments.push(head);
44
+ }
45
+ }
46
+ let line = '';
47
+ if (segments.length > 0)
48
+ line += ` · ${segments.join(' · ')}`;
49
+ if (s.partial)
50
+ line += ' · partial';
51
+ // LUM-775: what left the queue because the PR merged / closed, and why —
52
+ // so a merged PR's line explains where its findings went instead of
53
+ // silently listing fewer open rows than the scan found.
54
+ const exits = (0, security_scan_1.formatQueueExits)(s.queueExits);
55
+ if (exits)
56
+ line += ` · ${exits}`;
57
+ // LUM-756 (P8): only the prefixed, scrubbed scanner reason is printed, and
58
+ // only the text after the prefix (the caller has already applied the rule).
59
+ if (s.externalFailureReason) {
60
+ line += ` — ${(0, sanitize_1.sanitizeField)(tail(s.externalFailureReason, EXTERNAL_ERROR_TAIL))}`;
61
+ }
62
+ return line;
63
+ }
64
+ /**
65
+ * LUM-762 — the hunt's per-task read-out, one line under its scan:
66
+ * `Exploit paths 2/3 done · t1 idor 41.2s done · t2 injection 72.0s done
67
+ * (2 attempts) · t3 race skipped`. Headed by the same display label as the
68
+ * layer's own segment (LUM-763) — the detail line and the line above it
69
+ * must not name the layer two different ways. Elapsed time is summed over a
70
+ * task's attempts; a skipped task has none to print.
71
+ *
72
+ * LUM-758: a plan may hold two tasks of the same category, so the row is led
73
+ * by its task id. Pre-task-graph audits carry no id — the row then prints
74
+ * exactly as it always did.
75
+ */
76
+ function formatHuntCoverageLine(hunt) {
77
+ const parts = hunt.categories.map(c => {
78
+ const attempts = c.attempts > 1 ? ` (${c.attempts} attempts)` : '';
79
+ const spent = c.stopped === 'skipped' ? '' : `${(c.elapsedMs / 1000).toFixed(1)}s `;
80
+ const id = c.taskId === undefined ? '' : `${(0, sanitize_1.sanitizeField)(c.taskId)} `;
81
+ return `${id}${(0, sanitize_1.sanitizeField)(c.category)} ${spent}${(0, sanitize_1.sanitizeField)(c.stopped)}${attempts}`;
82
+ });
83
+ return `${(0, security_scan_1.scanStageLabel)('hunt')} ${hunt.done}/${hunt.total} done · ${parts.join(' · ')}`;
84
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isUnmetForDisplay = isUnmetForDisplay;
4
+ /** PASS 与 PASS_WITH_FOLLOWUP 都算通过(与 verification-run 仓库的
5
+ * isPassVerdict 同义;这里本地实现以保持本模块零依赖)。 */
6
+ function isPass(verdict) {
7
+ return verdict === 'PASS' || verdict === 'PASS_WITH_FOLLOWUP';
8
+ }
9
+ /**
10
+ * LUM-789 渲染口径:一条 criterion 是否该呈现为「未满足」。
11
+ *
12
+ * 未裁定的 HUMAN 项是 **tacit pass** —— 没人送回就是默认通过,与 DONE 闸
13
+ * 早已采用的口径一致(task-state.service.ts 的 assertNoUnresolvedSendBack
14
+ * 只对明确 FAIL 返回 409)。把它呈现成待办是纯摩擦:一个总会通过的闸产出
15
+ * 的不是信号,是伪装成信号的噪音。
16
+ *
17
+ * ⚠️ 这是 **渲染** 口径,不是裁决口径。裁决用的是
18
+ * task-human-verdict.service.ts 的 `allMet`(every criterion isPassVerdict),
19
+ * 它必须保持「未裁定 ≠ 通过」,否则在 web 上点一条 PASS 会把整个任务推成
20
+ * DONE。两者不得合并。
21
+ */
22
+ function isUnmetForDisplay(criterion, latest) {
23
+ if (isPass(latest?.verdict))
24
+ return false;
25
+ if (latest === undefined && criterion.verifierType === 'HUMAN')
26
+ return false;
27
+ return true;
28
+ }
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ /**
3
+ * LUM-788 — the per-task hunt read-out, shared by the CLI (`lumo pr scan
4
+ * --full`) and the lum739 analysis script. One implementation: the CLI is a
5
+ * separate package that cannot import `lib/` or `scripts/`, and a second copy
6
+ * of this printer is exactly how the audit lost four fields to "computed,
7
+ * carried, never read" before LUM-758.
8
+ *
9
+ * Pure formatting over the raw `PrSecurityScan.huntAudit.tasks[]` rows. Every
10
+ * field is optional by construction: the column is Json written by past
11
+ * versions of the workflow, and a row written before a field existed prints
12
+ * exactly as it always did — nothing is guessed, nothing is thrown over.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.formatHuntTaskDetail = formatHuntTaskDetail;
16
+ exports.formatShellDetail = formatShellDetail;
17
+ /** One line of prose, flattened, for a fixed-width read-out. */
18
+ function oneLine(text, max) {
19
+ const flat = text.replace(/\s+/g, ' ').trim();
20
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
21
+ }
22
+ /**
23
+ * The per-task listing for EVERY task handed in, idle ones included — the
24
+ * `lumo pr scan --full` shape. A task that never asked the shell for anything
25
+ * still has a stop line, a model and the sites it was briefed on, and "this
26
+ * task ran two steps and submitted nothing" is a fact the reader came for.
27
+ *
28
+ * Lines per task: a header (`t1 done · <model> · 3 sites · 2 cmds (gate
29
+ * refused …)`), one line per command (`✗` = the gate refused it, with its
30
+ * reason), one line per trace step, then the stop arithmetic and the
31
+ * wrap-up outcome — each only when the row carries it.
32
+ */
33
+ function formatHuntTaskDetail(tasks, maxTasks) {
34
+ const out = [];
35
+ for (const t of tasks.slice(0, maxTasks)) {
36
+ const sh = t.shell;
37
+ // LUM-787 — the model and the sites print only when the row carries them;
38
+ // the clip count likewise: an older row, and a row from a search that
39
+ // never hit the ceiling, read as before.
40
+ const cmds = sh?.calls
41
+ ? `${sh.calls} cmds (gate refused ${sh.refused ?? 0}, sandbox failed ${sh.failed ?? 0}, not found ${sh.notFound ?? 0}, empty ${sh.empty ?? 0}${sh.stepClipped ? `, step-clipped ${sh.stepClipped}` : ''})`
42
+ : 'no shell commands';
43
+ out.push(`${String(t.taskId)} ${String(t.stopped)}${t.model ? ` · ${t.model}` : ''}${typeof t.sites === 'number' ? ` · ${t.sites} sites` : ''} · ${cmds}`);
44
+ for (const c of sh?.commands ?? []) {
45
+ const cost = typeof c.chars === 'number'
46
+ ? ` [${c.chars} chars${c.compressed ? ', compressed' : ''}${c.clipped ? ', clipped' : ''}]`
47
+ : '';
48
+ out.push(` ${c.denied ? '✗' : ' '} ${c.cmd ?? ''}${cost}${c.reason ? ` ← ${c.reason}` : ''}`);
49
+ }
50
+ // LUM-781 — the model's side, step by step, then how it ended. Only when
51
+ // the row carries them: an older row prints exactly as before.
52
+ for (const st of t.trace ?? []) {
53
+ out.push(` #${st.i ?? '?'} tool=${st.tool ?? '(none)'} in=${st.inputTokens ?? '?'} out=${st.outputTokens ?? '?'}${st.reasoningTokens ? ` reasoning=${st.reasoningTokens}` : ''}${st.elidedChars ? ` elided=${st.elidedChars}` : ''}${st.finishReason ? ` finish=${st.finishReason}` : ''}${st.text ? ` "${oneLine(st.text, 160)}"` : ''}`);
54
+ }
55
+ if (t.stop) {
56
+ const s = t.stop;
57
+ out.push(` stop: ${s.by ?? '?'} · spent ${s.spent ?? '?'}/${s.budget ?? '?'} · reserve ${s.reserve ?? 0} · last step ${s.lastStep ?? '?'}${typeof s.next === 'number' ? ` · next ${s.next}` : ''} · ${s.steps ?? '?'} steps`);
58
+ }
59
+ if (t.wrapUp) {
60
+ const w = t.wrapUp;
61
+ out.push(w.ran
62
+ ? ` wrap-up: ran · submitted ${w.submitted ?? 0} · ${w.tokens ?? '?'} tokens${w.finishReason ? ` · finish=${w.finishReason}` : ''}${w.text ? ` · "${oneLine(w.text, 200)}"` : ''}`
63
+ : w.error
64
+ ? ` wrap-up: attempted, failed — ${oneLine(w.error, 160)}`
65
+ : ` wrap-up: not made (${w.skipped ?? 'unknown'})`);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * The per-task, command-by-command listing of the lum739 audit report —
72
+ * only tasks that actually asked the shell for something.
73
+ *
74
+ * The run-level `shell:` line says HOW MUCH; only this says WHAT — and telling
75
+ * "mapped the repo and ran out" from "the gate rejected everything" from
76
+ * "looked and found nothing" is the entire reason the field exists. Extracted
77
+ * as a pure function so it can be tested: a read-out nothing exercises is the
78
+ * same "computed, carried, never read" shape this layer has already lost four
79
+ * fields to.
80
+ */
81
+ function formatShellDetail(tasks, maxTasks) {
82
+ return formatHuntTaskDetail(tasks.filter(x => x.shell?.calls), maxTasks);
83
+ }
@@ -30,12 +30,29 @@
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.
41
+ *
42
+ * LUM-792 — the wrapper half needed the same medicine, on the harder path:
43
+ * `escapesWrapper` matched the tag name literally, so
44
+ * `</untrusted<U+200B>-team-memory>` — which an LLM reads as a closing tag —
45
+ * was not one, and the fence could be closed early. Tag matching now runs on
46
+ * the normalized copy as well, while the rewrite still happens in the
47
+ * original text. The same round widened the ZH imperatives from word-level to
48
+ * character-level separator tolerance.
33
49
  */
34
50
  Object.defineProperty(exports, "__esModule", { value: true });
35
- exports.INJECTION_SIGNAL_LABEL = exports.INJECTION_SIGNAL_KINDS = void 0;
51
+ exports.NORMALIZE_MAX_CHARS = exports.INJECTION_SIGNAL_LABEL = exports.INJECTION_SIGNAL_KINDS = void 0;
36
52
  exports.escapesWrapper = escapesWrapper;
37
53
  exports.countWrapperEscapes = countWrapperEscapes;
38
54
  exports.neutralizeWrapperEscapes = neutralizeWrapperEscapes;
55
+ exports.normalizeForMatching = normalizeForMatching;
39
56
  exports.detectInjectionSignals = detectInjectionSignals;
40
57
  exports.summarizeContentSignals = summarizeContentSignals;
41
58
  exports.isContentSignals = isContentSignals;
@@ -72,35 +89,109 @@ const WRAPPER_NAME = 'untrusted-[a-z0-9_-]*|system[-_]reminder|function_results?
72
89
  * stops `system-reminderx` from matching while still letting the greedy
73
90
  * `untrusted-[…]*` end on a `-` (the legacy regex matched `<untrusted->`).
74
91
  *
75
- * Built from a source string so the two consumers can hold a `g` and a
76
- * non-`g` instance: a shared `/g` regex advances `lastIndex` on `.test()` and
77
- * would make `escapesWrapper` flip-flop on repeated calls (the trap
78
- * `prompt-safety.ts` documents).
92
+ * Built from a source string, and every consumer compiles its own instance
93
+ * rather than sharing one: a shared `/g` regex advances `lastIndex` on
94
+ * `.test()` and would make `escapesWrapper` flip-flop on repeated calls (the
95
+ * trap `prompt-safety.ts` documents).
79
96
  */
80
97
  const WRAPPER_TAG_SOURCE = `<\\s*(\\/?)\\s*(${WRAPPER_NAME})(?=[\\s>\\/])([^<>]*)>`;
81
- const WRAPPER_TAG = new RegExp(WRAPPER_TAG_SOURCE, 'i');
82
- const WRAPPER_TAG_G = new RegExp(WRAPPER_TAG_SOURCE, 'gi');
98
+ /**
99
+ * The same shape anchored to a whole string. It answers one question about a
100
+ * span: is this *literally* a tag, or does it only read as one once padding
101
+ * has been normalized away?
102
+ */
103
+ const WRAPPER_TAG_EXACT = new RegExp(`^${WRAPPER_TAG_SOURCE}$`, 'i');
104
+ /** Angle brackets, including the full-width forms NFKC folds into them. */
105
+ const ANGLE_OPEN = /[<\uFF1C]/g;
106
+ const ANGLE_CLOSE = /[>\uFF1E]/g;
107
+ /**
108
+ * Every fence-shaped tag in `text`.
109
+ *
110
+ * LUM-792 — matched against the NORMALIZED copy, for the same reason the
111
+ * signal patterns are (LUM-791): the literal regex read the tag name and
112
+ * nothing else, so one invisible character inside it bought a full bypass —
113
+ * and on *this* path the consequence is not one missing advisory, it is a
114
+ * fence the content can close early. An LLM reads
115
+ * `</untrusted<U+200B>-team-memory>` as a closing tag; so must we.
116
+ *
117
+ * Spans come back in ORIGINAL coordinates because the caller rewrites the
118
+ * real text, not the copy. The end offset is mapped from the match's LAST
119
+ * character rather than from the position after it: an offset sitting exactly
120
+ * on a rewrite checkpoint resolves to the far side of it, which would swallow
121
+ * whatever padding happens to follow the tag.
122
+ */
123
+ function findWrapperTags(text) {
124
+ if (!text)
125
+ return [];
126
+ const norm = normalizeForMatching(text);
127
+ const re = new RegExp(WRAPPER_TAG_SOURCE, 'gi');
128
+ const spans = [];
129
+ let m;
130
+ while ((m = re.exec(norm.text)) !== null) {
131
+ const length = m[0].length;
132
+ if (length === 0) {
133
+ re.lastIndex++;
134
+ continue;
135
+ }
136
+ const start = norm.toOriginalIndex(m.index);
137
+ const end = Math.min(norm.toOriginalIndex(m.index + length - 1) + 1, text.length);
138
+ const prev = spans[spans.length - 1];
139
+ if (end <= start || (prev !== undefined && start < prev.end))
140
+ continue;
141
+ spans.push({ start, end });
142
+ }
143
+ return spans;
144
+ }
83
145
  /** True when the text carries any fence-shaped tag, opening or closing. */
84
146
  function escapesWrapper(text) {
85
- return WRAPPER_TAG.test(text);
147
+ return findWrapperTags(text).length > 0;
86
148
  }
87
149
  /** How many fence-shaped tags the text carries. */
88
150
  function countWrapperEscapes(text) {
89
- return text.match(WRAPPER_TAG_G)?.length ?? 0;
151
+ return findWrapperTags(text).length;
152
+ }
153
+ /**
154
+ * The inert form of one tag span.
155
+ *
156
+ * A span that is literally a tag gets the canonical LUM-783 rendering —
157
+ * `<x a="1">` → `(x a="1")`, `</x>` → `(/x)`, whitespace collapsed — which is
158
+ * what keeps the `untrusted-*` subset byte-identical to LUM-758's
159
+ * `neutralizeUntrustedTags`.
160
+ *
161
+ * A span that only reads as a tag once normalized was padded on purpose.
162
+ * There the brackets are defanged and every other character is kept, the
163
+ * padding included: the reason this module neutralizes rather than drops is
164
+ * that a reviewer gets to see what the content tried to do, and stripping the
165
+ * invisible characters would erase exactly the part worth seeing. Nothing
166
+ * that can parse as a tag survives either way — the brackets are gone.
167
+ */
168
+ function inertTag(raw) {
169
+ const literal = WRAPPER_TAG_EXACT.exec(raw);
170
+ if (literal) {
171
+ const attrs = (literal[3] ?? '').trim().replace(/\s+/g, ' ');
172
+ return `(${literal[1] ?? ''}${literal[2] ?? ''}${attrs ? ` ${attrs}` : ''})`;
173
+ }
174
+ return raw.replace(ANGLE_OPEN, '(').replace(ANGLE_CLOSE, ')');
90
175
  }
91
176
  /**
92
- * Turn every fence-shaped tag into inert text: `<x>` `(x)`, `</x>` → `(/x)`,
93
- * whitespace collapsed, attributes kept (a reviewer should still see what the
94
- * content tried to do). Iterates to a fixpoint so removing an inner tag can
95
- * never reassemble an outer one.
177
+ * Turn every fence-shaped tag into inert text. Iterates to a fixpoint so
178
+ * removing an inner tag can never reassemble an outer one.
96
179
  */
97
180
  function neutralizeWrapperEscapes(text) {
98
181
  let out = text;
99
182
  for (let i = 0; i < 8; i++) {
100
- const next = out.replace(WRAPPER_TAG_G, (_m, slash, name, attrs) => {
101
- const a = attrs.trim().replace(/\s+/g, ' ');
102
- return `(${slash}${name}${a ? ` ${a}` : ''})`;
103
- });
183
+ const spans = findWrapperTags(out);
184
+ if (spans.length === 0)
185
+ return out;
186
+ let next = '';
187
+ let cursor = 0;
188
+ for (const span of spans) {
189
+ next +=
190
+ out.slice(cursor, span.start) +
191
+ inertTag(out.slice(span.start, span.end));
192
+ cursor = span.end;
193
+ }
194
+ next += out.slice(cursor);
104
195
  if (next === out)
105
196
  return out;
106
197
  out = next;
@@ -133,21 +224,43 @@ const IMPERATIVE_EN = [
133
224
  // run / execute the following command
134
225
  /\b(?:run|execute)\s+(?:the\s+following|this|these|the\s+below)\s+(?:commands?|scripts?|code|instructions?|steps?|payload)\b/i,
135
226
  ];
227
+ /**
228
+ * Chinese is normally written without spaces between words, so inserting one
229
+ * costs a human reader nothing and used to break a contiguous character class
230
+ * outright (`忽略 之前 的所有指令` → no match). Every ZH pattern therefore
231
+ * joins its parts with a bounded separator class instead of butting them
232
+ * together. `{0,3}` keeps the gap too small to bridge unrelated sentences.
233
+ *
234
+ * LUM-792 — the same trick one level down. `忽 略 之 前 的 所 有 指 令`
235
+ * separates every *character*, which the word-level tolerance above does not
236
+ * cover; a human reads straight through it, and on origin/main it produced
237
+ * zero signals. `looseZh` therefore also admits a separator between two
238
+ * adjacent CJK characters — **at most one**, which is the whole precision
239
+ * story: a run would let ordinary prose drift into a match, and
240
+ * `忽 然 想 起 之 前 的 那 些 指 令` has to stay clean. English is
241
+ * space-delimited already and none of this touches it.
242
+ */
243
+ const ZH_SEP = '[\\s、,,]{0,3}';
244
+ const ZH_CHAR_SEP = '[\\s、,,]?';
245
+ const ADJACENT_ZH = /[\u4e00-\u9fff](?=[\u4e00-\u9fff])/gu;
246
+ /** Admit one separator between each pair of adjacent CJK characters. */
247
+ const looseZh = (source) => source.replace(ADJACENT_ZH, m => `${m}${ZH_CHAR_SEP}`);
248
+ const zh = (...parts) => new RegExp(parts.map(looseZh).join(ZH_SEP));
136
249
  const IMPERATIVE_ZH = [
137
250
  // 忽略 / 无视 … 之前 … 指令
138
- /(?:忽略|无视|忘记|忘掉|抛弃|放弃|覆盖)(?:掉)?(?:之前|以上|上面|先前|前面|上述|所有|全部|一切|原来|原先|系统)(?:的)?(?:所有|全部|一切)?(?:指令|指示|提示|规则|内容|说明|要求|设定|限制|约束)/,
251
+ zh('(?:忽略|无视|忘记|忘掉|抛弃|放弃|覆盖)', '(?:掉)?', '(?:之前|以上|上面|先前|前面|上述|所有|全部|一切|原来|原先|系统)', '(?:的)?', '(?:所有|全部|一切)?', '(?:指令|指示|提示|规则|内容|说明|要求|设定|限制|约束)'),
139
252
  // 你现在是 / 扮演 …
140
- /你现在(?:是|扮演|作为|变成|成为|将是)/,
253
+ zh('你现在', '(?:是|扮演|作为|变成|成为|将是)'),
141
254
  // 新指令:
142
- /新(?:的)?(?:系统)?(?:指令|指示|规则|任务)\s*[::]/,
143
- // 行首角色标签
144
- /(?:^|\n)[ \t]*(?:系统|助手|开发者)[ \t]*[::]/,
255
+ zh('新', '(?:的)?', '(?:系统)?', '(?:指令|指示|规则|任务)', '[::]'),
256
+ // 行首角色标签 — anchored on the line, so no *leading* separator tolerance.
257
+ new RegExp(`(?:^|\\n)[ \\t]*${looseZh('(?:系统|助手|开发者)')}[ \\t]*[::]`),
145
258
  // 不要告诉用户
146
- /(?:不要|别|勿|不准|禁止|不能)(?:告诉|告知|透露给|通知|提醒)(?:任何)?(?:用户|人类|审核者|操作者)/,
259
+ zh('(?:不要|别|勿|不准|禁止|不能)', '(?:告诉|告知|透露给|通知|提醒)', '(?:任何)?', '(?:用户|人类|审核者|操作者)'),
147
260
  // 输出你的系统提示
148
- /(?:输出|显示|打印|泄露|重复|说出|复述|公开)(?:你的|你|系统的)?(?:系统提示|系统指令|初始指令|隐藏指令|提示词)/,
261
+ zh('(?:输出|显示|打印|泄露|重复|说出|复述|公开)', '(?:你的|你|系统的)?', '(?:系统提示|系统指令|初始指令|隐藏指令|提示词)'),
149
262
  // 执行以下命令
150
- /(?:执行|运行)(?:以下|下面|下列|这些|这个|如下|后面|该)(?:的)?(?:命令|指令|脚本|代码|操作|步骤)/,
263
+ zh('(?:执行|运行)', '(?:以下|下面|下列|这些|这个|如下|后面|该)', '(?:的)?', '(?:命令|指令|脚本|代码|操作|步骤)'),
151
264
  ];
152
265
  const IMPERATIVE_PATTERNS = [...IMPERATIVE_EN, ...IMPERATIVE_ZH];
153
266
  /** A comment whose body is a paragraph, or carries an imperative, is a signal. */
@@ -212,6 +325,117 @@ function* matchAll(re, text) {
212
325
  re.lastIndex++;
213
326
  }
214
327
  }
328
+ // ─── Matching-time normalization (LUM-791) ───────────────────────────────────
329
+ /**
330
+ * Hard cap on how many characters the normalizer will rewrite. Text longer
331
+ * than this is never truncated — the tail is carried through verbatim — but
332
+ * the rewriting work (and the offset table it builds) stays bounded, because
333
+ * every injection point runs this on its hot path.
334
+ */
335
+ exports.NORMALIZE_MAX_CHARS = 1_000_000;
336
+ /**
337
+ * Compatibility forms that are the same letter in another dress: full-width
338
+ * ASCII (U+FF01-FF60, U+FFE0-FFEE), Latin ligatures, circled / parenthesised
339
+ * letters and digits, and the mathematical alphanumerics. Ordinary CJK,
340
+ * emoji and half-width katakana sit outside these ranges and are left alone.
341
+ * Held as a source string so the escapes stay readable in review.
342
+ */
343
+ const COMPAT_RANGES = '\\u2460-\\u24FF\\uFB00-\\uFB06\\uFF01-\\uFF60\\uFFE0-\\uFFEE\\u{1D400}-\\u{1D7FF}';
344
+ /** A single cheap scan: is there anything in here to rewrite at all? */
345
+ const NEEDS_NORMALIZE = new RegExp(`\\p{Cf}|[^\\S\\n]{2,}|[^\\S\\n\\x20]|[${COMPAT_RANGES}]`, 'u');
346
+ /**
347
+ * The three rewrite classes, in precedence order:
348
+ *
349
+ * 1. Unicode format characters (Cf) — zero-width, bidi controls, unicode
350
+ * tags, soft hyphen, BOM. Dropped: none of them carry meaning to a reader,
351
+ * all of them break a literal match.
352
+ * 2. Horizontal whitespace runs — tabs, NBSP, the full-width space U+3000 —
353
+ * folded to one ASCII space. Newlines are deliberately NOT folded: the
354
+ * role-label patterns are anchored on the start of a line.
355
+ * 3. Compatibility forms (COMPAT_RANGES), folded with NFKC.
356
+ */
357
+ const REWRITE = new RegExp(`\\p{Cf}+|[^\\S\\n]+|[${COMPAT_RANGES}]+`, 'gu');
358
+ const ALL_FORMAT = /^\p{Cf}+$/u;
359
+ const ALL_HORIZONTAL_WS = /^[^\S\n]+$/;
360
+ function rewriteRun(run) {
361
+ if (ALL_FORMAT.test(run))
362
+ return '';
363
+ if (ALL_HORIZONTAL_WS.test(run))
364
+ return ' ';
365
+ return run.normalize('NFKC');
366
+ }
367
+ /**
368
+ * Build the copy the injection patterns are matched against, plus the offset
369
+ * table that maps every position in it back to the original text.
370
+ *
371
+ * Pure: no module state, no shared `lastIndex`, same input → same output.
372
+ * Never throws. Text that needs nothing takes a fast path and is returned
373
+ * as-is, which is the overwhelmingly common case on the hot path.
374
+ */
375
+ function normalizeForMatching(text) {
376
+ if (!text || !NEEDS_NORMALIZE.test(text)) {
377
+ return {
378
+ text,
379
+ changed: false,
380
+ toOriginalIndex: (i) => Math.min(Math.max(i, 0), text.length),
381
+ };
382
+ }
383
+ const limit = Math.min(text.length, exports.NORMALIZE_MAX_CHARS);
384
+ const head = text.slice(0, limit);
385
+ const pieces = [];
386
+ // Checkpoints: normalized offset ↔ original offset, recorded at each
387
+ // rewrite. Between two checkpoints the mapping is a constant shift, so the
388
+ // table costs O(number of rewrites), not O(length).
389
+ const normAt = [0];
390
+ const origAt = [0];
391
+ let norm = 0;
392
+ let copied = 0;
393
+ const re = new RegExp(REWRITE.source, REWRITE.flags);
394
+ let m;
395
+ while ((m = re.exec(head)) !== null) {
396
+ const run = m[0];
397
+ if (run.length === 0) {
398
+ re.lastIndex++;
399
+ continue;
400
+ }
401
+ const replacement = rewriteRun(run);
402
+ if (replacement === run)
403
+ continue;
404
+ pieces.push(head.slice(copied, m.index), replacement);
405
+ norm += m.index - copied + replacement.length;
406
+ copied = m.index + run.length;
407
+ if (normAt[normAt.length - 1] === norm)
408
+ origAt[origAt.length - 1] = copied;
409
+ else {
410
+ normAt.push(norm);
411
+ origAt.push(copied);
412
+ }
413
+ }
414
+ // Everything left: the rest of the head plus any tail beyond the cap.
415
+ pieces.push(text.slice(copied));
416
+ const out = pieces.join('');
417
+ return {
418
+ text: out,
419
+ changed: out !== text,
420
+ toOriginalIndex: (i) => {
421
+ if (i <= 0)
422
+ return 0;
423
+ let lo = 0;
424
+ let hi = normAt.length - 1;
425
+ while (lo < hi) {
426
+ const mid = (lo + hi + 1) >> 1;
427
+ if (normAt[mid] <= i)
428
+ lo = mid;
429
+ else
430
+ hi = mid - 1;
431
+ }
432
+ // A compatibility fold can be longer than its source, so clamp to the
433
+ // next checkpoint rather than running past it.
434
+ const ceiling = lo + 1 < origAt.length ? origAt[lo + 1] : text.length;
435
+ return Math.min(origAt[lo] + (i - normAt[lo]), ceiling, text.length);
436
+ },
437
+ };
438
+ }
215
439
  /**
216
440
  * Scan untrusted text for content shaped like an instruction to the model.
217
441
  * Pure; never throws; empty input yields no signals. Signals come back in
@@ -221,41 +445,47 @@ function detectInjectionSignals(text) {
221
445
  if (!text)
222
446
  return { signals: [] };
223
447
  const signals = [];
448
+ // Patterns run against the normalized copy; every offset and excerpt the
449
+ // caller sees is translated back to the original text (LUM-791).
450
+ const normalized = normalizeForMatching(text);
451
+ const scan = normalized.text;
452
+ const original = (start, end) => text.slice(normalized.toOriginalIndex(start), normalized.toOriginalIndex(end));
224
453
  // ① imperative directives (EN + ZH)
225
454
  for (const p of IMPERATIVE_PATTERNS) {
226
455
  const g = new RegExp(p.source, p.flags.includes('g') ? p.flags : `${p.flags}g`);
227
- for (const m of matchAll(g, text)) {
456
+ for (const m of matchAll(g, scan)) {
228
457
  signals.push({
229
458
  kind: 'IMPERATIVE',
230
- index: m.index,
231
- excerpt: excerpt(m[0]),
459
+ index: normalized.toOriginalIndex(m.index),
460
+ excerpt: excerpt(original(m.index, m.index + m[0].length)),
232
461
  });
233
462
  }
234
463
  }
235
464
  // ② 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)) {
465
+ // The body is *tested* on the normalized copy (so a zero-width inside the
466
+ // comment cannot hide its imperative) and *shown* from the original.
467
+ const comment = (re, groups) => {
468
+ const d = new RegExp(re.source, `${re.flags}d`);
469
+ for (const m of matchAll(d, scan)) {
470
+ const g = groups.find(i => m[i] !== undefined);
471
+ if (g === undefined)
472
+ continue;
473
+ const body = m[g] ?? '';
474
+ const dense = body.replace(/\s+/g, '');
475
+ if (dense.length < HIDDEN_COMMENT_MIN_CHARS && !hasImperative(body))
476
+ continue;
477
+ const span = m.indices?.[g];
251
478
  signals.push({
252
479
  kind: 'HIDDEN_COMMENT',
253
- index: m.index,
254
- excerpt: excerpt(body),
480
+ index: normalized.toOriginalIndex(m.index),
481
+ excerpt: excerpt(span ? original(span[0], span[1]) : body),
255
482
  });
256
483
  }
257
- }
258
- // ③ zero-width / bidi / tag characters
484
+ };
485
+ comment(HTML_COMMENT, [1]);
486
+ comment(MARKDOWN_COMMENT, [1, 2, 3]);
487
+ // ③ zero-width / bidi / tag characters — on the ORIGINAL text by
488
+ // definition: these are exactly the characters normalization removes.
259
489
  {
260
490
  const strong = [...matchAll(STRONG_INVISIBLE, text)];
261
491
  // A leading BOM is a file artefact, not a hidden character.
@@ -276,7 +506,8 @@ function detectInjectionSignals(text) {
276
506
  });
277
507
  }
278
508
  }
279
- // ④ long high-entropy base64 / hex runs
509
+ // ④ long high-entropy base64 / hex runs — on the ORIGINAL text: the length
510
+ // floor and the entropy floors are calibrated against raw runs.
280
511
  for (const m of matchAll(ENCODED_RUN, text)) {
281
512
  const run = m[0];
282
513
  const floor = HEX_ONLY.test(run) ? HEX_MIN_ENTROPY : BASE64_MIN_ENTROPY;
@@ -288,14 +519,15 @@ function detectInjectionSignals(text) {
288
519
  });
289
520
  }
290
521
  }
291
- // ⑤ javascript: / vbscript: / data:<mime> links
522
+ // ⑤ javascript: / vbscript: / data:<mime> links — also on the normalized
523
+ // copy, so `java<U+200B>script:` cannot slip past the `\s*` tolerance.
292
524
  for (const p of SCRIPT_LINK_PATTERNS) {
293
525
  const g = new RegExp(p.source, `${p.flags}g`);
294
- for (const m of matchAll(g, text)) {
526
+ for (const m of matchAll(g, scan)) {
295
527
  signals.push({
296
528
  kind: 'SCRIPT_LINK',
297
- index: m.index,
298
- excerpt: excerpt(m[0]),
529
+ index: normalized.toOriginalIndex(m.index),
530
+ excerpt: excerpt(original(m.index, m.index + m[0].length)),
299
531
  });
300
532
  }
301
533
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.66.0",
3
+ "version": "1.69.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",