dsh-memory-recall 0.1.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/LICENSE +96 -0
- package/README.md +181 -0
- package/cordis.patch.yml +16 -0
- package/dsh.plugin.json +18 -0
- package/lib/client.js +1090 -0
- package/package.json +60 -0
- package/screenshots.json +6 -0
- package/src/chats.js +160 -0
- package/src/dsh-tools.js +75 -0
- package/src/index.js +156 -0
- package/src/memory-tool.js +136 -0
- package/src/recall-tool.js +203 -0
- package/src/recall.js +282 -0
- package/src/routes.js +153 -0
- package/src/snapshot.js +63 -0
- package/src/store.js +298 -0
- package/src/threats.js +113 -0
package/src/snapshot.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What a new chat's system prompt says about memory.
|
|
2
|
+
//
|
|
3
|
+
// Rendered ONCE, when the chat's agent starts, and never again for that chat.
|
|
4
|
+
// dsh keeps the prompt prefix cacheable only while its text is unchanged —
|
|
5
|
+
// "any change may invalidate reuse from the first changed token" — so a
|
|
6
|
+
// memory saved mid-chat is written to disk at once but reaches the prompt in
|
|
7
|
+
// the next new chat. The guidance says so, so the model does not expect to
|
|
8
|
+
// see its own save reflected, and does not save twice.
|
|
9
|
+
|
|
10
|
+
import { TARGETS, joinEntries, sizeOf } from './store.js';
|
|
11
|
+
|
|
12
|
+
/** After prompt-notes (950) and every first-party instruction, before the environment suffix (10000). */
|
|
13
|
+
export const SECTION_ORDER = 9500;
|
|
14
|
+
export const SECTION_NAME = 'dsh-memory-recall:memory';
|
|
15
|
+
|
|
16
|
+
const RULE = '═'.repeat(46);
|
|
17
|
+
|
|
18
|
+
export const GUIDANCE = [
|
|
19
|
+
'# Memory',
|
|
20
|
+
'',
|
|
21
|
+
'You have a persistent memory that carries across chats: the blocks below, when they hold anything. It is fixed for this chat —',
|
|
22
|
+
'what you save now is on disk at once but appears here from the next new chat, so do not re-save something because you do not see it yet.',
|
|
23
|
+
'',
|
|
24
|
+
"- Save with the `memory` tool. Target 'user' is who the user is: name, role, preferences, how they like to work.",
|
|
25
|
+
" Target 'memory' is your own notes: environment facts, conventions, tool quirks, lessons that apply to every task.",
|
|
26
|
+
'- Save durable facts, one short fact per entry, when you learn them — not task progress, one-off details, or anything easy to look up again.',
|
|
27
|
+
'- The user sees each save as a "Saved to memory" note in the chat and can edit or delete entries in the Memory panel.',
|
|
28
|
+
' Never save secrets or credentials, or anything the user would not want written down.',
|
|
29
|
+
'- When the user refers to an earlier conversation ("what did we decide about…", "like last time"), use `session_search`.',
|
|
30
|
+
' It asks the user for permission to read past chats first. If they decline, say so and carry on without it; do not ask again in the same reply.',
|
|
31
|
+
'- A user message carrying [workspace "Name" — folder] means the user picked that workspace with @: pass it as `workspace` to',
|
|
32
|
+
' session_search. A chat the user @-mentions arrives as a "Referenced sessions" snapshot — use that directly, no search needed.',
|
|
33
|
+
].join('\n');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The note when the tools failed to load: the memory below can be read, but
|
|
37
|
+
* nothing can be saved or searched — and the model must say so rather than
|
|
38
|
+
* improvise a substitute (it once wrote a notes file instead).
|
|
39
|
+
*/
|
|
40
|
+
export const GUIDANCE_NO_TOOLS = [
|
|
41
|
+
'# Memory',
|
|
42
|
+
'',
|
|
43
|
+
'You have a persistent memory that carries across chats: the blocks below, when they hold anything.',
|
|
44
|
+
'Its tools (`memory` and `session_search`) failed to load in this dsh, so right now you cannot save to memory or search',
|
|
45
|
+
'earlier chats. If the user asks you to remember something, tell them memory saving is unavailable in this session and',
|
|
46
|
+
'that they can add it themselves in the Memory panel in the sidebar. Do not substitute a file or anything else for it.',
|
|
47
|
+
].join('\n');
|
|
48
|
+
|
|
49
|
+
/** One store as a prompt block, or '' when it is empty. */
|
|
50
|
+
export function renderBlock(target, entries, limit) {
|
|
51
|
+
if (!entries.length) return '';
|
|
52
|
+
const chars = sizeOf(entries);
|
|
53
|
+
const percent = limit > 0 ? Math.min(100, Math.floor((chars / limit) * 100)) : 0;
|
|
54
|
+
const usage = `${percent}% — ${chars.toLocaleString('en-US')}/${limit.toLocaleString('en-US')} chars`;
|
|
55
|
+
return `${RULE}\n${TARGETS[target].title} [${usage}]\n${RULE}\n${joinEntries(entries)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The whole section: guidance, then the user profile, then the notes. */
|
|
59
|
+
export function renderSection({ user = [], memory = [] }, limits, { tools = true } = {}) {
|
|
60
|
+
const guidance = tools ? GUIDANCE : GUIDANCE_NO_TOOLS;
|
|
61
|
+
const blocks = [renderBlock('user', user, limits.user), renderBlock('memory', memory, limits.memory)].filter(Boolean);
|
|
62
|
+
return blocks.length ? `${guidance}\n\n${blocks.join('\n\n')}` : `${guidance}\n\n(Nothing is saved yet.)`;
|
|
63
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// The two memory files, and every change made to them.
|
|
2
|
+
//
|
|
3
|
+
// USER.md who the user is — name, role, preferences, how they like to work
|
|
4
|
+
// MEMORY.md the model's own notes — environment facts, conventions, lessons
|
|
5
|
+
//
|
|
6
|
+
// Each file is a list of short entries separated by a line holding only `§`,
|
|
7
|
+
// the format Hermes Agent uses, so the files stay readable and hand-editable.
|
|
8
|
+
// Each has a character budget: everything in them is pasted into the system
|
|
9
|
+
// prompt of every new chat, so they must stay small, and a full store makes
|
|
10
|
+
// the model consolidate (merge or drop stale entries) rather than grow.
|
|
11
|
+
//
|
|
12
|
+
// Every change re-reads the file first, so an edit made by hand or in the
|
|
13
|
+
// Memory panel is never overwritten by a stale copy; writes go to a temporary
|
|
14
|
+
// file and are renamed into place, so a crash never leaves half a file; and
|
|
15
|
+
// changes run one at a time.
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { firstThreatMessage } from './threats.js';
|
|
21
|
+
|
|
22
|
+
export const ENTRY_DELIMITER = '\n§\n';
|
|
23
|
+
const SPLIT = /\r?\n§\r?\n/;
|
|
24
|
+
|
|
25
|
+
export const TARGETS = {
|
|
26
|
+
user: { file: 'USER.md', label: 'user profile', title: 'USER PROFILE (who the user is)' },
|
|
27
|
+
memory: { file: 'MEMORY.md', label: 'memory', title: 'MEMORY (your personal notes)' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Hermes Agent's defaults: 2,200 characters of notes, 1,375 of user profile. */
|
|
31
|
+
export const DEFAULT_LIMITS = { memory: 2200, user: 1375 };
|
|
32
|
+
|
|
33
|
+
/** Entries in a file's text: trimmed, blanks dropped, exact duplicates dropped. */
|
|
34
|
+
export function parseEntries(raw) {
|
|
35
|
+
if (typeof raw !== 'string') return [];
|
|
36
|
+
const text = raw.replace(/^\uFEFF/, ''); // a BOM from Notepad would stick to the first entry
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const part of text.split(SPLIT)) {
|
|
39
|
+
const entry = part.trim();
|
|
40
|
+
if (entry !== '' && !out.includes(entry)) out.push(entry);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const joinEntries = (entries) => entries.join(ENTRY_DELIMITER);
|
|
46
|
+
|
|
47
|
+
/** Characters an entry list costs, as the prompt will carry it. */
|
|
48
|
+
export const sizeOf = (entries) => joinEntries(entries).length;
|
|
49
|
+
|
|
50
|
+
function isTarget(target) {
|
|
51
|
+
return Object.hasOwn(TARGETS, target);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What makes an entry unusable as an entry, or null. */
|
|
55
|
+
function entryProblem(content) {
|
|
56
|
+
if (typeof content !== 'string' || content.trim() === '') return 'the entry is empty';
|
|
57
|
+
if (/(^|\r?\n)§(\r?\n|$)/.test(content.trim())) return 'an entry must not contain a line holding only §, which separates entries';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class MemoryStore {
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} options
|
|
64
|
+
* @param {string} options.dir where USER.md and MEMORY.md live
|
|
65
|
+
* @param {object} [options.limits] {memory, user} character budgets
|
|
66
|
+
*/
|
|
67
|
+
constructor({ dir, limits = {} }) {
|
|
68
|
+
this.dir = dir;
|
|
69
|
+
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
|
70
|
+
this.entries = { user: [], memory: [] };
|
|
71
|
+
this.queue = Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pathFor(target) {
|
|
75
|
+
return join(this.dir, TARGETS[target].file);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
limit(target) {
|
|
79
|
+
return this.limits[target];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `{chars, limit, percent}` for a target, or for a hypothetical entry list. */
|
|
83
|
+
usage(target, entries = this.entries[target]) {
|
|
84
|
+
const chars = sizeOf(entries);
|
|
85
|
+
const limit = this.limit(target);
|
|
86
|
+
return { chars, limit, percent: limit > 0 ? Math.min(100, Math.floor((chars / limit) * 100)) : 0 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A copy of both lists. */
|
|
90
|
+
snapshot() {
|
|
91
|
+
return { user: [...this.entries.user], memory: [...this.entries.memory] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async readTarget(target) {
|
|
95
|
+
try {
|
|
96
|
+
return parseEntries(await readFile(this.pathFor(target), 'utf8'));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.code === 'ENOENT') return [];
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read both files synchronously — two small files, read once when a chat
|
|
105
|
+
* starts, so the snapshot is in place before that chat's first prompt is
|
|
106
|
+
* assembled rather than racing it. A file that cannot be read keeps the last
|
|
107
|
+
* good copy instead of briefing the chat with an empty memory.
|
|
108
|
+
*/
|
|
109
|
+
loadSync() {
|
|
110
|
+
for (const target of Object.keys(TARGETS)) {
|
|
111
|
+
try {
|
|
112
|
+
this.entries[target] = parseEntries(readFileSync(this.pathFor(target), 'utf8'));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error?.code === 'ENOENT') this.entries[target] = [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return this.snapshot();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Read both files from disk. */
|
|
121
|
+
async load() {
|
|
122
|
+
this.entries.user = await this.readTarget('user');
|
|
123
|
+
this.entries.memory = await this.readTarget('memory');
|
|
124
|
+
return this.snapshot();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async writeTarget(target, entries) {
|
|
128
|
+
await mkdir(this.dir, { recursive: true });
|
|
129
|
+
const path = this.pathFor(target);
|
|
130
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
131
|
+
await writeFile(temp, entries.length ? `${joinEntries(entries)}\n` : '', 'utf8');
|
|
132
|
+
await rename(temp, path);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Run `work` after every earlier change has finished, whatever its outcome. */
|
|
136
|
+
serial(work) {
|
|
137
|
+
const run = this.queue.then(work, work);
|
|
138
|
+
this.queue = run.catch(() => {});
|
|
139
|
+
return run;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Re-read `target`, let `mutate(entries)` return `{entries, message, changed}`
|
|
144
|
+
* or a failure, then save. The read is fresh every time so a hand edit made
|
|
145
|
+
* since the last change is kept.
|
|
146
|
+
*/
|
|
147
|
+
change(target, action, mutate) {
|
|
148
|
+
return this.serial(async () => {
|
|
149
|
+
if (!isTarget(target)) return this.fail(target, `unknown target '${target}' — use 'memory' or 'user'`);
|
|
150
|
+
let current;
|
|
151
|
+
try {
|
|
152
|
+
current = await this.readTarget(target);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return this.fail(target, `could not read ${TARGETS[target].file} (${error?.code || error?.message || error}); nothing was changed`);
|
|
155
|
+
}
|
|
156
|
+
this.entries[target] = current;
|
|
157
|
+
const result = mutate([...current]);
|
|
158
|
+
if (result.success === false) return result;
|
|
159
|
+
try {
|
|
160
|
+
await this.writeTarget(target, result.entries);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return this.fail(target, `could not save ${TARGETS[target].file} (${error?.code || error?.message || error}); nothing was changed`);
|
|
163
|
+
}
|
|
164
|
+
this.entries[target] = result.entries;
|
|
165
|
+
return {
|
|
166
|
+
success: true,
|
|
167
|
+
target,
|
|
168
|
+
action,
|
|
169
|
+
message: result.message,
|
|
170
|
+
changed: result.changed,
|
|
171
|
+
usage: this.usage(target),
|
|
172
|
+
entryCount: result.entries.length,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fail(target, error, extra = {}) {
|
|
178
|
+
const known = isTarget(target);
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
target,
|
|
182
|
+
error,
|
|
183
|
+
...(known ? { usage: this.usage(target) } : {}),
|
|
184
|
+
...extra,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A failure that carries the live entries, so the model can consolidate. */
|
|
189
|
+
failWithEntries(target, entries, error) {
|
|
190
|
+
return this.fail(target, error, { currentEntries: entries, usage: this.usage(target, entries) });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The index of the one entry containing `oldText`, or a failure. */
|
|
194
|
+
findOne(target, entries, oldText) {
|
|
195
|
+
if (typeof oldText !== 'string' || oldText.trim() === '') {
|
|
196
|
+
return this.failWithEntries(target, entries, 'old_text is required: a short piece of the entry to change, unique to it');
|
|
197
|
+
}
|
|
198
|
+
const needle = oldText.trim();
|
|
199
|
+
const hits = entries.map((e, i) => (e.includes(needle) ? i : -1)).filter((i) => i >= 0);
|
|
200
|
+
if (hits.length === 0) return this.failWithEntries(target, entries, `no ${TARGETS[target].label} entry contains "${needle}"`);
|
|
201
|
+
if (hits.length > 1) {
|
|
202
|
+
return this.failWithEntries(target, entries, `"${needle}" is in ${hits.length} entries — use a longer piece that is unique to one`);
|
|
203
|
+
}
|
|
204
|
+
return hits[0];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Apply one operation to an entry list in memory; no budget check, no save. */
|
|
208
|
+
step(target, entries, op, { scan }) {
|
|
209
|
+
const action = op?.action;
|
|
210
|
+
const content = typeof op?.content === 'string' ? op.content : typeof op?.new_text === 'string' ? op.new_text : undefined;
|
|
211
|
+
if (action === 'add' || action === 'replace') {
|
|
212
|
+
const problem = entryProblem(content);
|
|
213
|
+
if (problem) return this.failWithEntries(target, entries, `${action}: ${problem}`);
|
|
214
|
+
if (scan) {
|
|
215
|
+
const threat = firstThreatMessage(content);
|
|
216
|
+
if (threat) return this.fail(target, threat, { blocked: true });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (action === 'add') {
|
|
220
|
+
const entry = content.trim();
|
|
221
|
+
if (entries.includes(entry)) return { entries, changed: { added: [] }, note: 'already saved' };
|
|
222
|
+
return { entries: [...entries, entry], changed: { added: [entry] } };
|
|
223
|
+
}
|
|
224
|
+
if (action === 'replace' || action === 'remove') {
|
|
225
|
+
const at = this.findOne(target, entries, op.old_text);
|
|
226
|
+
if (typeof at !== 'number') return at;
|
|
227
|
+
const before = entries[at];
|
|
228
|
+
if (action === 'remove') {
|
|
229
|
+
return { entries: entries.filter((_, i) => i !== at), changed: { removed: [before] } };
|
|
230
|
+
}
|
|
231
|
+
const after = content.trim();
|
|
232
|
+
const next = [...entries];
|
|
233
|
+
if (next.includes(after) && after !== before) next.splice(at, 1); // merging into an existing entry
|
|
234
|
+
else next[at] = after;
|
|
235
|
+
return { entries: next, changed: { replaced: [{ from: before, to: after }] } };
|
|
236
|
+
}
|
|
237
|
+
return this.failWithEntries(target, entries, `unknown action '${action}' — use add, replace or remove`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
overBudget(target, entries, what) {
|
|
241
|
+
const { chars, limit } = this.usage(target, entries);
|
|
242
|
+
return this.failWithEntries(target, this.entries[target],
|
|
243
|
+
`${what} would bring the ${TARGETS[target].label} to ${chars.toLocaleString('en-US')}/${limit.toLocaleString('en-US')} characters — over its budget. `
|
|
244
|
+
+ 'Consolidate in ONE memory call: an operations batch that replaces overlapping entries with shorter merged ones or removes stale ones, '
|
|
245
|
+
+ 'and adds the new entry, all together. The budget is checked on the final result.');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** @param {{scan?: boolean}} [options] scan=false for edits the user makes themselves */
|
|
249
|
+
add(target, content, { scan = true } = {}) {
|
|
250
|
+
return this.change(target, 'add', (entries) => {
|
|
251
|
+
const r = this.step(target, entries, { action: 'add', content }, { scan });
|
|
252
|
+
if (r.success === false) return r;
|
|
253
|
+
if (r.note === 'already saved') return { entries, message: 'That entry is already saved — nothing changed.', changed: { added: [] } };
|
|
254
|
+
if (sizeOf(r.entries) > this.limit(target)) return this.overBudget(target, r.entries, 'Adding this entry');
|
|
255
|
+
return { entries: r.entries, message: 'Entry added.', changed: r.changed };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
replace(target, oldText, content, { scan = true } = {}) {
|
|
260
|
+
return this.change(target, 'replace', (entries) => {
|
|
261
|
+
const r = this.step(target, entries, { action: 'replace', old_text: oldText, content }, { scan });
|
|
262
|
+
if (r.success === false) return r;
|
|
263
|
+
if (sizeOf(r.entries) > this.limit(target)) return this.overBudget(target, r.entries, 'This replacement');
|
|
264
|
+
return { entries: r.entries, message: 'Entry replaced.', changed: r.changed };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
remove(target, oldText) {
|
|
269
|
+
return this.change(target, 'remove', (entries) => {
|
|
270
|
+
const r = this.step(target, entries, { action: 'remove', old_text: oldText }, { scan: false });
|
|
271
|
+
if (r.success === false) return r;
|
|
272
|
+
return { entries: r.entries, message: 'Entry removed.', changed: r.changed };
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Several operations as one change: applied in order to a copy, the budget
|
|
278
|
+
* checked once on the final list, and nothing written unless every step
|
|
279
|
+
* works. This is what lets a full store make room and add in one call.
|
|
280
|
+
*/
|
|
281
|
+
applyBatch(target, operations, { scan = true } = {}) {
|
|
282
|
+
return this.change(target, 'batch', (entries) => {
|
|
283
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
284
|
+
return this.failWithEntries(target, entries, 'operations must be a non-empty list of {action, content?, old_text?}');
|
|
285
|
+
}
|
|
286
|
+
let list = entries;
|
|
287
|
+
const changed = { added: [], removed: [], replaced: [] };
|
|
288
|
+
for (const [index, op] of operations.entries()) {
|
|
289
|
+
const r = this.step(target, list, op, { scan });
|
|
290
|
+
if (r.success === false) return { ...r, error: `operation ${index + 1} (${op?.action ?? '?'}): ${r.error} — nothing was changed` };
|
|
291
|
+
list = r.entries;
|
|
292
|
+
for (const key of Object.keys(changed)) if (r.changed?.[key]) changed[key].push(...r.changed[key]);
|
|
293
|
+
}
|
|
294
|
+
if (sizeOf(list) > this.limit(target)) return this.overBudget(target, list, 'This batch');
|
|
295
|
+
return { entries: list, message: `${operations.length} change(s) applied.`, changed };
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
package/src/threats.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Refuse memory writes that read like a prompt injection or an exfiltration.
|
|
2
|
+
//
|
|
3
|
+
// Memory is pasted into the system prompt of every chat that starts after it
|
|
4
|
+
// is saved, so a poisoned entry is not a one-off: it keeps instructing the
|
|
5
|
+
// model in every future session until someone notices and deletes it. Every
|
|
6
|
+
// write the MODEL makes is scanned here first; a hit refuses the write and
|
|
7
|
+
// says why. (Edits you make yourself in the Memory panel are yours and are not
|
|
8
|
+
// scanned.)
|
|
9
|
+
//
|
|
10
|
+
// Ported from Hermes Agent's tools/threat_patterns.py — the scope it applies to
|
|
11
|
+
// memory writes, which is every pattern it has — with Hermes-specific paths
|
|
12
|
+
// swapped for dsh's. Hermes Agent is MIT licensed, (c) 2025 Nous Research.
|
|
13
|
+
// The rule it states for new patterns applies here too: anchor on attack
|
|
14
|
+
// vocabulary or unambiguous attack behaviour, not on bossy English ("you must"
|
|
15
|
+
// is common in legitimate notes).
|
|
16
|
+
|
|
17
|
+
/** Scanners are advisory: bound the worst case. */
|
|
18
|
+
export const MAX_SCAN_CHARS = 65_536;
|
|
19
|
+
|
|
20
|
+
// Bounded filler between key words; an unbounded `(?:\w+\s+)*` backtracks badly.
|
|
21
|
+
const F = String.raw`(?:\w+\s+){0,8}`;
|
|
22
|
+
// An env var whose name ENDS in a secret-ish word ($OPENAI_API_KEY, ${TOKEN}),
|
|
23
|
+
// so a benign $SOME_KEYBOARD_URL passes.
|
|
24
|
+
const SECRET_VAR = String.raw`\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b`;
|
|
25
|
+
const MODIFY = String.raw`(update|modify|edit|write|change|append|add\s+to)\s+[^\n]{0,2048}`;
|
|
26
|
+
|
|
27
|
+
/** [pattern source, id] — all matched case-insensitively. */
|
|
28
|
+
const PATTERNS = [
|
|
29
|
+
// Classic prompt injection
|
|
30
|
+
[String.raw`ignore\s+${F}(previous|all|above|prior)\s+${F}instructions`, 'prompt_injection'],
|
|
31
|
+
[String.raw`system\s+prompt\s+override`, 'sys_prompt_override'],
|
|
32
|
+
[String.raw`disregard\s+${F}(your|all|any)\s+${F}(instructions|rules|guidelines)`, 'disregard_rules'],
|
|
33
|
+
[String.raw`act\s+as\s+(if|though)\s+${F}you\s+${F}(have\s+no|don't\s+have)\s+${F}(restrictions|limits|rules)`, 'bypass_restrictions'],
|
|
34
|
+
[String.raw`<!--[^>]{0,512}(?:ignore|override|system|secret|hidden)[^>]{0,512}-->`, 'html_comment_injection'],
|
|
35
|
+
[String.raw`<\s*div\s+style\s*=\s*["'][^>]{0,2048}display\s*:\s*none`, 'hidden_div'],
|
|
36
|
+
[String.raw`translate\s+[^\n]{0,512}\s+into\s+\w+(?:[\s-]+\w+){0,2}\s+and\s+(execute|run|eval)\b`, 'translate_execute'],
|
|
37
|
+
[String.raw`do\s+not\s+${F}tell\s+${F}the\s+user`, 'deception_hide'],
|
|
38
|
+
|
|
39
|
+
// Role-play / identity hijack
|
|
40
|
+
[String.raw`you\s+are\s+${F}now\s+(?:a|an|the)\s+`, 'role_hijack'],
|
|
41
|
+
[String.raw`pretend\s+${F}(you\s+are|to\s+be)\s+`, 'role_pretend'],
|
|
42
|
+
[String.raw`output\s+${F}(system|initial)\s+prompt`, 'leak_system_prompt'],
|
|
43
|
+
[String.raw`(respond|answer|reply)\s+without\s+${F}(restrictions|limitations|filters|safety)`, 'remove_filters'],
|
|
44
|
+
[String.raw`you\s+have\s+been\s+${F}(updated|upgraded|patched)\s+to`, 'fake_update'],
|
|
45
|
+
[String.raw`\bname\s+yourself\s+\w+`, 'identity_override'],
|
|
46
|
+
|
|
47
|
+
// Command-and-control style promptware
|
|
48
|
+
[String.raw`register\s+(as\s+)?a?\s*node`, 'c2_node_registration'],
|
|
49
|
+
[String.raw`(heartbeat|beacon|check[\s\-]?in)\s+(to|with)\s+`, 'c2_heartbeat'],
|
|
50
|
+
[String.raw`pull\s+(down\s+)?(?:new\s+)?task(?:ing|s)?\b`, 'c2_task_pull'],
|
|
51
|
+
[String.raw`connect\s+to\s+the\s+network\b`, 'c2_network_connect'],
|
|
52
|
+
[String.raw`you\s+must\s+(?:\w+\s+){0,3}(register|connect|report|beacon)\b`, 'forced_action'],
|
|
53
|
+
[String.raw`only\s+use\s+one[\s\-]?liners?\b`, 'anti_forensic_oneliner'],
|
|
54
|
+
[String.raw`never\s+${F}(?:create|write)\s+${F}(?:script|file)\s+${F}disk`, 'anti_forensic_disk'],
|
|
55
|
+
[String.raw`unset\s+\w*(?:CLAUDE|CODEX|HERMES|AGENT|OPENAI|ANTHROPIC|DEEPSEEK|DSH)\w*`, 'env_var_unset_agent'],
|
|
56
|
+
[String.raw`\b(?:cobalt\s*strike|sliver|havoc|mythic|metasploit|brainworm)\b`, 'known_c2_framework'],
|
|
57
|
+
[String.raw`\bc2\s+(?:server|channel|infrastructure|beacon)\b`, 'c2_explicit'],
|
|
58
|
+
[String.raw`\bcommand\s+and\s+control\b`, 'c2_explicit_long'],
|
|
59
|
+
|
|
60
|
+
// Exfiltration
|
|
61
|
+
[String.raw`curl\s+[^\n]{0,2048}${SECRET_VAR}`, 'exfil_curl'],
|
|
62
|
+
[String.raw`wget\s+[^\n]{0,2048}${SECRET_VAR}`, 'exfil_wget'],
|
|
63
|
+
[String.raw`cat\s+[^\n]{0,2048}(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)`, 'read_secrets'],
|
|
64
|
+
[String.raw`(send|post|upload|transmit)\s+[^\n]{0,2048}\s+(to|at)\s+https?://`, 'send_to_url'],
|
|
65
|
+
[String.raw`(include|output|print|share)\s+${F}(conversation|chat\s+history|previous\s+messages|full\s+context|entire\s+context)`, 'context_exfil'],
|
|
66
|
+
|
|
67
|
+
// Persistence / backdoors, and tampering with the agent's own configuration
|
|
68
|
+
[String.raw`authorized_keys`, 'ssh_backdoor'],
|
|
69
|
+
[String.raw`\$HOME/\.ssh|~/\.ssh`, 'ssh_access'],
|
|
70
|
+
[String.raw`\$HOME/\.dsh/\.env|~/\.dsh/\.env`, 'dsh_env'],
|
|
71
|
+
[String.raw`${MODIFY}(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)`, 'agent_config_mod'],
|
|
72
|
+
[String.raw`${MODIFY}\.dsh/(settings\.yaml|cordis\.patch\.yml|cordis\.yml)`, 'dsh_config_mod'],
|
|
73
|
+
|
|
74
|
+
// Hardcoded secrets
|
|
75
|
+
[String.raw`(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'][A-Za-z0-9+/=_-]{20,}`, 'hardcoded_secret'],
|
|
76
|
+
].map(([source, id]) => [new RegExp(source, 'i'), id]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Zero-width and bidirectional-control characters: invisible in any editor,
|
|
80
|
+
* used to hide instructions inside innocent-looking text.
|
|
81
|
+
*/
|
|
82
|
+
export const INVISIBLE_CHARS = new Set([
|
|
83
|
+
'\u200B', '\u200C', '\u200D', '\u2060', '\u2062', '\u2063', '\u2064', '\uFEFF',
|
|
84
|
+
'\u202A', '\u202B', '\u202C', '\u202D', '\u202E', '\u2066', '\u2067', '\u2068', '\u2069',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** Every pattern id found in `content`; invisible characters as `invisible_unicode_U+XXXX`. */
|
|
88
|
+
export function scanForThreats(content) {
|
|
89
|
+
if (typeof content !== 'string' || content === '') return [];
|
|
90
|
+
const text = content.slice(0, MAX_SCAN_CHARS);
|
|
91
|
+
const found = [];
|
|
92
|
+
// On the raw text: NFKC below can remove some of these code points.
|
|
93
|
+
for (const ch of new Set(text)) {
|
|
94
|
+
if (INVISIBLE_CHARS.has(ch)) found.push(`invisible_unicode_U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`);
|
|
95
|
+
}
|
|
96
|
+
// NFKC folds full-width and compatibility forms (cat → cat), so a
|
|
97
|
+
// homograph spelling does not slip past. Cross-script look-alikes
|
|
98
|
+
// (Cyrillic а) are not folded — that needs a confusables table.
|
|
99
|
+
const folded = text.normalize('NFKC');
|
|
100
|
+
for (const [pattern, id] of PATTERNS) if (pattern.test(folded)) found.push(id);
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A user-facing refusal for the first threat found, or null. */
|
|
105
|
+
export function firstThreatMessage(content) {
|
|
106
|
+
const [first] = scanForThreats(content);
|
|
107
|
+
if (first === undefined) return null;
|
|
108
|
+
if (first.startsWith('invisible_unicode_')) {
|
|
109
|
+
return `Blocked: the entry contains an invisible unicode character ${first.slice('invisible_unicode_'.length)} (possible hidden instruction).`;
|
|
110
|
+
}
|
|
111
|
+
return `Blocked: the entry matches the threat pattern '${first}'. Memory is placed in the system prompt of every future chat, `
|
|
112
|
+
+ 'so it must not contain injection or exfiltration instructions.';
|
|
113
|
+
}
|