@lumoai/cli 1.68.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.
|
@@ -226,6 +226,35 @@ Content is **always normalized to English** before storing — the memory store
|
|
|
226
226
|
a single canonical language. If you supply text in another language the CLI
|
|
227
227
|
translates it automatically; the stored memory will be in English.
|
|
228
228
|
|
|
229
|
+
### The write gate — what gets refused (LUM-793)
|
|
230
|
+
|
|
231
|
+
Memory is the one agent-writable store that is re-injected into **every** future
|
|
232
|
+
session, so the content checks run at **write** time, not only at injection
|
|
233
|
+
time. Every path — `memory add`, `memory push`, the web form, `PATCH
|
|
234
|
+
/api/memories/<id>`, and the LLM curation / fold passes — goes through the same
|
|
235
|
+
gate. A refusal is a **422** with the matched sentence quoted; nothing is
|
|
236
|
+
stored. Three reasons:
|
|
237
|
+
|
|
238
|
+
| Reason | What trips it |
|
|
239
|
+
| ------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
|
240
|
+
| `INJECTION_SIGNAL` | An imperative aimed at the model, text hidden in a comment, or a long encoded blob. |
|
|
241
|
+
| `AGENT_DIRECTIVE` | A **standing order to future agents** — "from now on you must …", 「以后每次…都要…」 — rather than a description. |
|
|
242
|
+
| `POLICY_CONFLICT` | The text permits what a deterministic, blocking CLAUDE.md rule forbids; the message names the rule id. |
|
|
243
|
+
|
|
244
|
+
What does **not** trip it: ordinary imperative PROCEDURAL steps ("Run `lumo
|
|
245
|
+
verify` when done", "All tests go in `__tests__/`"), or invisible characters /
|
|
246
|
+
`javascript:` links — those are still stored and marked on the row, exactly as
|
|
247
|
+
LUM-783 does for every other source card.
|
|
248
|
+
|
|
249
|
+
**If a write is refused, rewrite the memory as a description of what happens**
|
|
250
|
+
(what the trap is, what the convention is) instead of an instruction to whoever
|
|
251
|
+
reads it next. Do not try to smuggle the same sentence past the gate.
|
|
252
|
+
|
|
253
|
+
`lumo memory rm` on a **TRAP** memory that other tasks were already judged to
|
|
254
|
+
have _used_ records a `USED_TRAP_MEMORY_DELETED` boundary crossing against the
|
|
255
|
+
bound task (it blocks DONE until dispositioned). Correct or retire such a memory
|
|
256
|
+
rather than deleting it; deleting a never-used memory records nothing.
|
|
257
|
+
|
|
229
258
|
### Lumo memory vs the harness memory tool
|
|
230
259
|
|
|
231
260
|
Claude Code / the Claude API may expose a file-based **memory tool** (a
|
|
@@ -49,7 +49,15 @@ async function memoryRm(memoryId, options) {
|
|
|
49
49
|
const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
|
|
50
50
|
const base = (0, api_1.trimTrailingSlash)(apiUrl);
|
|
51
51
|
const url = `${base}/api/memories/${encodeURIComponent(memoryId)}`;
|
|
52
|
-
const headers = {
|
|
52
|
+
const headers = {
|
|
53
|
+
Authorization: `Bearer ${creds.token}`,
|
|
54
|
+
};
|
|
55
|
+
// LUM-793: the server attributes a used-TRAP deletion to the task this
|
|
56
|
+
// session is attached to — without the header it can prove no binding and
|
|
57
|
+
// records nothing.
|
|
58
|
+
const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
|
|
59
|
+
if (sessionId)
|
|
60
|
+
headers['X-Lumo-Session-Id'] = sessionId;
|
|
53
61
|
const notFound = `Error: memory ${memoryId} not found — pass the full memory id (cuid) from \`lumo task memory list\` / \`lumo project memory list\`; truncated id prefixes are not resolved`;
|
|
54
62
|
// LUM-755: without --confirm, fetch the card so the envelope shows what
|
|
55
63
|
// would be deleted, then stop — no DELETE is sent.
|
|
@@ -38,6 +38,14 @@
|
|
|
38
38
|
* and a lone zero-width is deliberately below `WEAK_INVISIBLE_MIN_RUN`, so
|
|
39
39
|
* both defences missed it at once. Normalization is orthogonal to precision —
|
|
40
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.
|
|
41
49
|
*/
|
|
42
50
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
51
|
exports.NORMALIZE_MAX_CHARS = exports.INJECTION_SIGNAL_LABEL = exports.INJECTION_SIGNAL_KINDS = void 0;
|
|
@@ -81,35 +89,109 @@ const WRAPPER_NAME = 'untrusted-[a-z0-9_-]*|system[-_]reminder|function_results?
|
|
|
81
89
|
* stops `system-reminderx` from matching while still letting the greedy
|
|
82
90
|
* `untrusted-[…]*` end on a `-` (the legacy regex matched `<untrusted->`).
|
|
83
91
|
*
|
|
84
|
-
* Built from a source string
|
|
85
|
-
*
|
|
86
|
-
* would make `escapesWrapper` flip-flop on repeated calls (the
|
|
87
|
-
* `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).
|
|
88
96
|
*/
|
|
89
97
|
const WRAPPER_TAG_SOURCE = `<\\s*(\\/?)\\s*(${WRAPPER_NAME})(?=[\\s>\\/])([^<>]*)>`;
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|
|
92
145
|
/** True when the text carries any fence-shaped tag, opening or closing. */
|
|
93
146
|
function escapesWrapper(text) {
|
|
94
|
-
return
|
|
147
|
+
return findWrapperTags(text).length > 0;
|
|
95
148
|
}
|
|
96
149
|
/** How many fence-shaped tags the text carries. */
|
|
97
150
|
function countWrapperEscapes(text) {
|
|
98
|
-
return text.
|
|
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, ')');
|
|
99
175
|
}
|
|
100
176
|
/**
|
|
101
|
-
* Turn every fence-shaped tag into inert text
|
|
102
|
-
*
|
|
103
|
-
* content tried to do). Iterates to a fixpoint so removing an inner tag can
|
|
104
|
-
* 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.
|
|
105
179
|
*/
|
|
106
180
|
function neutralizeWrapperEscapes(text) {
|
|
107
181
|
let out = text;
|
|
108
182
|
for (let i = 0; i < 8; i++) {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
return
|
|
112
|
-
|
|
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);
|
|
113
195
|
if (next === out)
|
|
114
196
|
return out;
|
|
115
197
|
out = next;
|
|
@@ -148,9 +230,22 @@ const IMPERATIVE_EN = [
|
|
|
148
230
|
* outright (`忽略 之前 的所有指令` → no match). Every ZH pattern therefore
|
|
149
231
|
* joins its parts with a bounded separator class instead of butting them
|
|
150
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.
|
|
151
242
|
*/
|
|
152
243
|
const ZH_SEP = '[\\s、,,]{0,3}';
|
|
153
|
-
const
|
|
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));
|
|
154
249
|
const IMPERATIVE_ZH = [
|
|
155
250
|
// 忽略 / 无视 … 之前 … 指令
|
|
156
251
|
zh('(?:忽略|无视|忘记|忘掉|抛弃|放弃|覆盖)', '(?:掉)?', '(?:之前|以上|上面|先前|前面|上述|所有|全部|一切|原来|原先|系统)', '(?:的)?', '(?:所有|全部|一切)?', '(?:指令|指示|提示|规则|内容|说明|要求|设定|限制|约束)'),
|
|
@@ -158,8 +253,8 @@ const IMPERATIVE_ZH = [
|
|
|
158
253
|
zh('你现在', '(?:是|扮演|作为|变成|成为|将是)'),
|
|
159
254
|
// 新指令:
|
|
160
255
|
zh('新', '(?:的)?', '(?:系统)?', '(?:指令|指示|规则|任务)', '[::]'),
|
|
161
|
-
// 行首角色标签 — anchored on the line, so no separator tolerance
|
|
162
|
-
|
|
256
|
+
// 行首角色标签 — anchored on the line, so no *leading* separator tolerance.
|
|
257
|
+
new RegExp(`(?:^|\\n)[ \\t]*${looseZh('(?:系统|助手|开发者)')}[ \\t]*[::]`),
|
|
163
258
|
// 不要告诉用户
|
|
164
259
|
zh('(?:不要|别|勿|不准|禁止|不能)', '(?:告诉|告知|透露给|通知|提醒)', '(?:任何)?', '(?:用户|人类|审核者|操作者)'),
|
|
165
260
|
// 输出你的系统提示
|