Skip to content

Commit b6aa236

Browse files
authored
Merge pull request #64 from levelcodeai/feat/memory-hardening
feat(memory): close M4's security half — a planted memory can't self-promote
2 parents 95ddcad + 988be27 commit b6aa236

4 files changed

Lines changed: 372 additions & 8 deletions

File tree

docs/levelcode-sessions-memory.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ This is the part most designs skip, and LevelCode can't (it's the security-forwa
128128

129129
- **Poisoning via untrusted content.** Sessions contain workspace text, which in a hostile repo is attacker-controlled. A naïve extractor could be steered into writing a false "memory" (*"the deploy token is safe to print"*). Mitigations: extraction summarizes **outcomes and user/agent actions, not arbitrary quoted content**; the digest is **bounded and reviewable**; and injected memory is **framed as untrusted, verify-first** (§4) — it can inform, never command.
130130
- **Memory never executes.** It is context in the system block, exactly like project rules. It cannot run a tool, approve an MCP call, or edit a file. An injected instruction inside a "memory" is treated like any other untrusted text (the project's existing prompt-injection posture).
131-
- **Provenance limits blast radius.** Because every item is sourced and dated, a poisoned entry is traceable to its session and removable in one click — and its low, *inferred* confidence keeps it from being load-bearing until a human confirms it.
131+
- **Provenance limits blast radius.** Because every item is sourced and dated, a poisoned entry is traceable to its session and removable in one click.
132+
- **Instruction-shaped text never self-promotes.** ⚠️ This bullet used to claim that "low, *inferred* confidence keeps it from being load-bearing until a human confirms it." **That was not what the code did.** `foldFacts` promoted anything observed in ≥ 2 distinct sessions with no human in the loop — and against a hostile repo, repetition is not corroboration: the planted file is still checked out next session, so one piece of evidence gets counted twice. Repetition still promotes ordinary facts, but text that reads as an *order* (`always …`, `never …`, `you must …`, `ignore previous instructions`, anything piping into a shell) now requires an explicit Confirm. It is still recorded and listed — surfaced, not silently dropped, so you can see what a repo tried to plant. Pinned by `test/memoryPoisoning.test.js`.
133+
- **Secrets are scrubbed at the write boundary.** The extractor's prompt asks the model not to emit credentials, and a request is not a filter. `redactSecrets()` strips the named key shapes (GitHub, Anthropic, OpenAI, Stripe, AWS, Google, Slack, bearer tokens, PEM private keys) from fact text, session titles and refined summaries *before* they reach `facts.jsonl` / `journal.jsonl` — files the user is explicitly invited to open, grep and check into a dotfiles repo. Named prefixes only, never a "looks random" heuristic: git SHAs, content hashes and asset names are legitimate things for a fact to mention, and corrupting a true fact is a worse failure than missing an exotic token shape.
132134
- **Local & private.** Memory never leaves the machine (BYOK promise); M9 sync, if enabled later, encrypts it like the sessions themselves.
133135

134136
---
@@ -165,7 +167,13 @@ The magic, delivered quietly (never a wall of text):
165167

166168
**M3 — the memory surface & control** *(M)*. The "Project memory" panel tab: view/edit/pin/delete/"not true", inferred-vs-confirmed, per-project off. Exit: a user corrects a wrong memory and the agent stops repeating it.
167169

168-
**M4 — polish & safety hardening** *(S)*. Conflict reconciliation UI, poisoning red-team pass, decayed-entry recall, export. Exit: an adversarial repo cannot plant a load-bearing memory; EXIT-TEST.md green.
170+
**M4 — polish & safety hardening** *(S)*.
171+
172+
-**Conflict reconciliation** — semantic supersede: a newer session's fact marks an older one obsolete, dimmed and restorable rather than silently replaced.
173+
-**Poisoning red-team pass.** `test/memoryPoisoning.test.js` — 34 cases, an adversarial corpus in the style of `commandSafety.test.js`: ten hostile shapes that must never self-promote, benign project facts that must keep working, nine credential shapes that must never reach disk, and the near-misses (git SHAs, content hashes, asset names) that must survive untouched. It found the gap it was written to look for — see §7. Every case verified non-vacuous by bypassing each guard and confirming failure.
174+
*Exit met: an adversarial repo cannot plant a load-bearing memory.* The original wording said "EXIT-TEST.md green", but that file is the **M0** fork/build checklist and was never the right home for this; an executable corpus is a better exit test than a checklist anyway, since it re-runs on every change.
175+
-**Decayed-entry recall** — surfacing an aged-out fact when a query matches it directly.
176+
-**Export** — "Copy as Markdown" for a session, and for the memory set. Cheap, since the storage is already plain text, and it seeds LevelLinks.
169177

170178
**Deliberately later:** cross-*project* memory ("how did I do idempotency in the *other* service?"); a vector cache over the plain files for large corpora; team-shared project memory (rides M9 sync).
171179

extensions/levelcode-ai/sessionMemory.js

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,15 @@ function memoryMdFile(root, slug) { return path.join(memoryDir(root, slug), 'MEM
3939
*/
4040
function outcomeEntry(derived, t) {
4141
const d = derived || {};
42-
const files = Array.isArray(d.filesEdited) ? d.filesEdited.slice(0, 6) : [];
43-
const title = d.title != null ? String(d.title) : null;
42+
// Paths get redacted too. They are not free text, but they are not safe either: digestMarkdown
43+
// prints them straight into MEMORY.md ("- did X (a.js, b.js)"), and a path is attacker-influenced
44+
// in a hostile repo and user-influenced everywhere else — a downloaded `key-ghp_….txt`, an `.env`
45+
// backup named after the token it holds. Cheap, and no legitimate path carries a credential prefix.
46+
const files = (Array.isArray(d.filesEdited) ? d.filesEdited.slice(0, 6) : []).map((f) => redactSecrets(String(f)));
47+
// The title is derived from the session's opening message, so a user who pasted a token into
48+
// chat to ask about it would otherwise have it copied into journal.jsonl and MEMORY.md — files
49+
// that outlive the session and are meant to be greppable and checkinable.
50+
const title = d.title != null ? redactSecrets(String(d.title)) : null;
4451
return {
4552
v: SCHEMA_V,
4653
id: d.id != null ? String(d.id) : null, // source_session — provenance
@@ -107,16 +114,97 @@ function latestBySession(entries) {
107114
function normalizeFactKey(text) {
108115
return String(text || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
109116
}
117+
// ---- Hardening: memory is an attack surface (design §7) ---------------------------------------
118+
//
119+
// Everything a session records passes through here on its way to disk. The two guards below are
120+
// DETERMINISTIC on purpose. The extractor's system prompt already asks the model never to emit
121+
// secrets or instructions, and that instruction is worth keeping — but a request is not a filter,
122+
// and the transcript it summarizes contains repo file contents, command output and MCP tool
123+
// results, all of which are attacker-controlled for any repo you clone.
124+
125+
/** Credential shapes worth refusing outright. Named prefixes only — see redactSecrets. */
126+
const SECRET_PATTERNS = [
127+
/-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g,
128+
/-----BEGIN[A-Z ]*PRIVATE KEY-----/g, // a truncated block still names a key
129+
/\bsk-ant-[A-Za-z0-9_-]{20,}/g, // Anthropic
130+
/\bsk-[A-Za-z0-9]{32,}/g, // OpenAI-shaped
131+
/\bsk_(?:live|test)_[A-Za-z0-9]{16,}/g, // Stripe
132+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub PAT / OAuth / server / refresh
133+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
134+
/\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
135+
/\bAIza[0-9A-Za-z_-]{30,}/g, // Google API key (39 chars today; unanchored length, since
136+
// pinning it exactly means a format tweak slips straight through)
137+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack
138+
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi // a bearer token pasted from a curl
139+
];
140+
141+
/**
142+
* Replace credential-shaped substrings with a marker, before the text is written anywhere.
143+
*
144+
* Deliberately NAMED shapes rather than a "long random-looking string" heuristic. The generic
145+
* version flags git SHAs, content hashes, base64 fixtures and long identifiers — all legitimate
146+
* things for a project fact to mention — and a memory system that quietly corrupts true facts is
147+
* a worse failure than one that misses an exotic token shape. These prefixes cover what actually
148+
* leaks in practice.
149+
*
150+
* The marker is left IN PLACE rather than dropping the whole line, so the surrounding fact stays
151+
* readable and the user can see that something was scrubbed instead of wondering why a sentence
152+
* ends abruptly.
153+
*/
154+
function redactSecrets(text) {
155+
let s = String(text == null ? '' : text);
156+
for (const re of SECRET_PATTERNS) { s = s.replace(re, '[redacted]'); }
157+
return s;
158+
}
159+
160+
/**
161+
* Does this read as an INSTRUCTION rather than a fact?
162+
*
163+
* A project fact is a stable truth — "the changelog is RELEASE-NOTES.md", "idempotency keys live
164+
* in Redis". An instruction is a command that will be replayed into the system prompt of every
165+
* future session in this project, which is the exact shape of a persistent prompt injection:
166+
* poison once, influence every run.
167+
*
168+
* This does not delete anything. It only withholds AUTOMATIC promotion — see foldFacts. The fact
169+
* is still recorded, still listed, and one Confirm click still activates it. That asymmetry is the
170+
* whole design: a false positive costs the user one click, a false negative is an attacker-authored
171+
* line injected into every session indefinitely.
172+
*
173+
* So yes, "Never commit .env files" — a real and useful convention — needs confirming. That is the
174+
* right trade at this price.
175+
*/
176+
const INSTRUCTION_PATTERNS = [
177+
// Imperative openers. Anchored: "the team should never…" is a description, "Never…" is an order.
178+
/^\s*(always|never|do not|don't|dont|ignore|disregard|forget|instead of|make sure|be sure|remember to|ensure that|you must|you should|you are|from now on)\b/i,
179+
// Injection boilerplate, wherever it appears.
180+
/\b(ignore (all )?(previous|prior|earlier) (instructions|prompts|rules)|system prompt|new instructions|override .{0,20}(instructions|rules))\b/i,
181+
// Piping anything into a shell is never a "fact".
182+
/\|\s*(sudo\s+)?(sh|bash|zsh|python3?)\b/i,
183+
/\b(curl|wget)\b[^\n]{0,80}\|/i
184+
];
185+
function looksLikeInstruction(text) {
186+
const s = String(text == null ? '' : text).trim();
187+
if (!s) { return false; }
188+
return INSTRUCTION_PATTERNS.some((re) => re.test(s));
189+
}
190+
110191
/** One observation of a candidate fact (append-only), sourced + dated — the raw material foldFacts counts. */
111192
function factObservation(text, sourceId, t) {
112-
return { v: SCHEMA_V, text: String(text == null ? '' : text).trim(), source: sourceId != null ? String(sourceId) : null, at: t || null };
193+
// Redact HERE, at the boundary, not at read time: facts.jsonl is a plain file the user can open,
194+
// grep, and check into a dotfiles repo. A secret scrubbed only on the way out would still be
195+
// sitting on disk.
196+
return { v: SCHEMA_V, text: redactSecrets(String(text == null ? '' : text).trim()), source: sourceId != null ? String(sourceId) : null, at: t || null };
113197
}
114198
/** A control event on a fact, by normalized key: confirm, remove (not-true), or supersede (a newer fact made
115199
* it obsolete — carries `by`, the replacing text, as the one-line history). */
116200
function factControl(key, action, t, by) {
117201
const control = action === 'remove' ? 'remove' : action === 'supersede' ? 'supersede' : 'confirm';
118202
const e = { v: SCHEMA_V, key: String(key || ''), control, at: t || null };
119-
if (control === 'supersede' && by) { e.by = String(by); } // the fact that replaced it — the one-line history
203+
// `by` is a SECOND copy of the replacing fact's text, taken straight from the model's output
204+
// (extension.js: `r.facts[0] || r.summary`) rather than from the observation that factObservation
205+
// already scrubbed. It persists to facts.jsonl and surfaces as `supersededBy` in the panel, so
206+
// without this it was a way around the boundary — same text, different door.
207+
if (control === 'supersede' && by) { e.by = redactSecrets(String(by)); } // the fact that replaced it — the one-line history
120208
return e;
121209
}
122210
/** Append fact observations and/or control events (JSONL). Creates memory/ on first write. */
@@ -171,7 +259,25 @@ function foldFacts(entries, opts) {
171259
if (g.removed || !g.text) { continue; }
172260
const count = g.sources.size;
173261
const superseded = !!g.superseded && !g.confirmed;
174-
out.push({ key: g.key, text: g.text, count, confirmed: g.confirmed, superseded, supersededBy: superseded ? g.supersededBy : '', inferred: !g.confirmed, active: g.confirmed || (!superseded && count >= minSeen), at: g.at });
262+
// Instruction-shaped text never rides the repetition path — only an explicit Confirm.
263+
//
264+
// Repetition is the weaker of the two promotion routes, and against a hostile repo it is not
265+
// evidence at all: the poisoned file is still checked out on the next session, so the
266+
// extractor reads the same line again and "seen in 2 distinct sessions" counts one planted
267+
// string twice. That is fine for a genuine observation, which is why the rule stays for
268+
// ordinary facts — but it means repetition cannot be what promotes an order into the system
269+
// prompt of every future run.
270+
const instruction = looksLikeInstruction(g.text);
271+
out.push({
272+
key: g.key, text: g.text, count, confirmed: g.confirmed, superseded,
273+
supersededBy: superseded ? g.supersededBy : '',
274+
inferred: !g.confirmed,
275+
// Surfaced, not hidden: the panel can show WHY this one is sitting inactive, the same way
276+
// a superseded fact is dimmed rather than dropped.
277+
instruction,
278+
active: g.confirmed || (!superseded && !instruction && count >= minSeen),
279+
at: g.at
280+
});
175281
}
176282
out.sort((a, b) => (Number(b.confirmed) - Number(a.confirmed)) || (Number(a.superseded) - Number(b.superseded)) || (b.count - a.count) || String(b.at || '').localeCompare(String(a.at || '')));
177283
return out;
@@ -308,5 +414,6 @@ module.exports = {
308414
memoryDir, journalFile, factsFile, memoryMdFile,
309415
outcomeEntry, appendJournal, readJournal, latestBySession, writeMemoryMd,
310416
normalizeFactKey, factObservation, factControl, appendFacts, readFacts, foldFacts, activeFacts,
417+
redactSecrets, looksLikeInstruction,
311418
queryTerms, snippetFor, recallRank, buildDigest, digestSummary, digestMarkdown
312419
};

extensions/levelcode-ai/sessions.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ function createSessions(opts) {
257257
try {
258258
const latest = memory.latestBySession(memory.readJournal(root, slug)).find((e) => e.id === id);
259259
if (!latest) { return false; }
260-
memory.appendJournal(root, slug, Object.assign({}, latest, { summary: String(summary).trim(), refined: true }));
260+
// Model output summarizing a transcript that contained repo files, command output and MCP
261+
// results — redact before it lands in journal.jsonl and, from there, MEMORY.md.
262+
memory.appendJournal(root, slug, Object.assign({}, latest, { summary: memory.redactSecrets(String(summary).trim()), refined: true }));
261263
consolidate();
262264
return true;
263265
} catch (e) { return false; }

0 commit comments

Comments
 (0)