@inetafrica/open-claudia 2.6.25 → 2.6.26

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/core/dream.js CHANGED
@@ -17,6 +17,7 @@ const CONFIG_DIR = require("../config-dir");
17
17
  const { config } = require("./config");
18
18
  const packs = require("./packs");
19
19
  const entities = require("./entities");
20
+ const packGuard = require("./pack-guard");
20
21
  const persona = require("./persona");
21
22
  const { spawnSubagent } = require("./subagent");
22
23
 
@@ -180,6 +181,12 @@ function applyDream(decision, backupRoot) {
180
181
  const into = m?.into && packs.readPack(m.into);
181
182
  const from = [].concat(m?.from || []).filter((d) => d && d !== m.into && !gone.has(d) && packs.readPack(d));
182
183
  if (!into || from.length === 0) continue;
184
+ const mGuard = packGuard.scanSections({ stance: m.stance, procedure: m.procedure, state: m.state, journal: m.journal });
185
+ if (mGuard.flagged) {
186
+ console.warn(`[dream] guard blocked merge into ${m.into} (${mGuard.kind} in ${mGuard.section})`);
187
+ lines.push(`šŸ›”ļø Skipped merge into ${m.into} — content looked like an injected instruction (${mGuard.kind})`);
188
+ continue;
189
+ }
183
190
  const mr = packs.updatePack(m.into, {
184
191
  stance: typeof m.stance === "string" ? m.stance : "",
185
192
  procedure: typeof m.procedure === "string" ? m.procedure : "",
@@ -202,6 +209,12 @@ function applyDream(decision, backupRoot) {
202
209
  if (!dir || gone.has(dir)) continue;
203
210
  const children = [].concat(u?.children || []).filter((d) => d && d !== dir && !gone.has(d) && packs.readPack(d));
204
211
  if (children.length < 2) continue;
212
+ const uGuard = packGuard.scanSections({ description: u.description, state: u.state });
213
+ if (uGuard.flagged) {
214
+ console.warn(`[dream] guard blocked umbrella ${dir} (${uGuard.kind} in ${uGuard.section})`);
215
+ lines.push(`šŸ›”ļø Skipped umbrella ${dir} — content looked like an injected instruction (${uGuard.kind})`);
216
+ continue;
217
+ }
205
218
  if (!packs.readPack(dir)) {
206
219
  packs.createPack({
207
220
  dir,
@@ -242,6 +255,11 @@ function applyDream(decision, backupRoot) {
242
255
  const desc = typeof r.description === "string" ? r.description.trim() : "";
243
256
  const tags = Array.isArray(r.tags) ? r.tags.map((t) => String(t).trim()).filter(Boolean).slice(0, 6) : [];
244
257
  if (!desc && tags.length === 0) continue;
258
+ const rGuard = packGuard.scanForInjection(desc, { strict: true });
259
+ if (rGuard.flagged) {
260
+ console.warn(`[dream] guard blocked retag of ${r.pack} (${rGuard.kind})`);
261
+ continue;
262
+ }
245
263
  packs.updatePack(r.pack, { description: desc, tags }, "dream");
246
264
  lines.push(`šŸ·ļø Sharpened ${r.pack}'s description/tags`);
247
265
  } catch (e) { console.warn(`[dream] retag failed: ${e.message}`); }
@@ -0,0 +1,82 @@
1
+ // Content threat-scan for memory writes (spec #4).
2
+ //
3
+ // Threat model: prompt-injection rides in via tool output or a pasted doc,
4
+ // gets echoed in the assistant reply, and the background reviewer (or dream)
5
+ // faithfully writes "ignore previous instructions / always run X / exfiltrate
6
+ // to URL" into a pack section — which then auto-injects every session as if it
7
+ // were the user's own rule. Stance/Procedure/State auto-inject, so they are the
8
+ // dangerous sections; Journal is shown on demand, so it is scanned leniently
9
+ // (exfil only). We never import marketplace packs, so this is the whole surface.
10
+ //
11
+ // This is a heuristic gate, not a classifier. It is deliberately conservative:
12
+ // it only fires on phrasings that have no legitimate reason to appear in a
13
+ // memory section a model wrote about a past turn. On a hit we SKIP the write —
14
+ // a missed memory is cheap, a poisoned auto-injected rule is not.
15
+
16
+ // Imperative override / instruction-hijack phrasing.
17
+ const OVERRIDE_PATTERNS = [
18
+ /\bignore\s+(all\s+)?(previous|prior|earlier|above|the\s+following)\s+(instructions?|prompts?|rules?|context)/i,
19
+ /\bdisregard\s+(all\s+)?(previous|prior|earlier|above|your)\s+(instructions?|prompts?|rules?|training)/i,
20
+ /\bforget\s+(everything|all|your\s+(instructions?|rules?|prompt))/i,
21
+ /\boverride\s+(your|the|all)\s+(instructions?|rules?|system\s+prompt|guardrails?)/i,
22
+ /\b(you\s+(must|should|will)\s+always|always\s+remember\s+to)\b.*\b(run|execute|send|post|upload|reveal|disclose|delete|drop)\b/i,
23
+ /\bfrom\s+now\s+on\b.*\b(ignore|disregard|always|never)\b/i,
24
+ /\bdo\s+not\s+(tell|inform|ask|notify)\s+(the\s+)?(user|owner|sumeet)\b/i,
25
+ /\bsystem\s+prompt\b.*\b(ignore|reveal|print|output|leak)\b/i,
26
+ /\b(reveal|print|output|leak|disclose)\b.*\b(system\s+prompt|api\s+key|token|password|secret|credential)/i,
27
+ ];
28
+
29
+ // Exfiltration: a send/post/upload verb pointed at a URL or address. Scanned in
30
+ // every section, including Journal.
31
+ const EXFIL_PATTERNS = [
32
+ /\b(send|post|upload|exfiltrate|forward|transmit|leak|email|curl|wget|fetch)\b[^.\n]{0,60}\b(to|at|into)\b[^.\n]{0,40}(https?:\/\/|ftp:\/\/|[\w.-]+@[\w.-]+)/i,
33
+ /\b(https?:\/\/)[^\s)]+[^\s)]*\b.*\b(api\s*key|token|password|secret|credential|env)/i,
34
+ /\bcurl\b[^\n]*\b(https?:\/\/)/i,
35
+ ];
36
+
37
+ // A long unbroken base64 blob — no reason for one to live in a prose memory
38
+ // section; classic carrier for an obfuscated payload.
39
+ const BASE64_BLOB = /[A-Za-z0-9+/]{120,}={0,2}/;
40
+
41
+ function matchAny(patterns, text) {
42
+ for (const re of patterns) {
43
+ const m = re.exec(text);
44
+ if (m) return m[0].slice(0, 80);
45
+ }
46
+ return null;
47
+ }
48
+
49
+ // Scan a single piece of text. `strict` = true for auto-injected sections
50
+ // (Stance/Procedure/State), false for Journal (exfil-only).
51
+ function scanForInjection(text, { strict = true } = {}) {
52
+ const s = String(text || "");
53
+ if (!s.trim()) return { flagged: false };
54
+
55
+ const exfil = matchAny(EXFIL_PATTERNS, s);
56
+ if (exfil) return { flagged: true, kind: "exfil", evidence: exfil };
57
+
58
+ if (!strict) return { flagged: false };
59
+
60
+ const override = matchAny(OVERRIDE_PATTERNS, s);
61
+ if (override) return { flagged: true, kind: "override", evidence: override };
62
+
63
+ const b64 = BASE64_BLOB.exec(s);
64
+ if (b64) return { flagged: true, kind: "base64", evidence: b64[0].slice(0, 40) + "…" };
65
+
66
+ return { flagged: false };
67
+ }
68
+
69
+ // Scan the sections of a pending pack write. Strict on the auto-injected
70
+ // sections, lenient on Journal. Returns the first hit, or { flagged: false }.
71
+ function scanSections(sections = {}) {
72
+ const strictKeys = ["stance", "procedure", "state", "description"];
73
+ for (const key of strictKeys) {
74
+ const r = scanForInjection(sections[key], { strict: true });
75
+ if (r.flagged) return { ...r, section: key };
76
+ }
77
+ const r = scanForInjection(sections.journal, { strict: false });
78
+ if (r.flagged) return { ...r, section: "journal" };
79
+ return { flagged: false };
80
+ }
81
+
82
+ module.exports = { scanForInjection, scanSections };
@@ -9,6 +9,7 @@
9
9
  const { spawnSubagent } = require("./subagent");
10
10
  const packs = require("./packs");
11
11
  const entities = require("./entities");
12
+ const packGuard = require("./pack-guard");
12
13
  const { redactSensitive } = require("./redact");
13
14
 
14
15
  const MIN_TURN_CHARS = 400;
@@ -124,8 +125,26 @@ function parseDecision(text) {
124
125
  }
125
126
  }
126
127
 
128
+ function guardCheck(a) {
129
+ const hit = packGuard.scanSections({
130
+ stance: a.stance,
131
+ procedure: a.procedure,
132
+ state: a.state,
133
+ description: a.description,
134
+ journal: a.journal,
135
+ });
136
+ if (hit.flagged) {
137
+ console.warn(`[pack-review] guard blocked write to ${a.pack || a.dir || a.name} (${hit.kind} in ${hit.section}): ${hit.evidence}`);
138
+ }
139
+ return hit;
140
+ }
141
+
127
142
  function applyAction(a) {
128
143
  if (!a || typeof a !== "object") return null;
144
+ const guard = guardCheck(a);
145
+ if (guard.flagged) {
146
+ return { kind: "skipped", dir: a.pack || a.dir || "", name: a.name || a.pack || a.dir || "", reason: `${guard.kind} in ${guard.section}` };
147
+ }
129
148
  if (a.action === "update" && a.pack) {
130
149
  const existing = packs.readPack(a.pack);
131
150
  if (!existing) return null;
@@ -192,7 +211,9 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
192
211
  for (const a of decision.actions) {
193
212
  try {
194
213
  const r = applyAction(a);
195
- if (r) {
214
+ if (r && r.kind === "skipped") {
215
+ lines.push(`šŸ›”ļø Skipped a memory write that looked like an injected instruction (${r.reason}).`);
216
+ } else if (r) {
196
217
  lines.push(r.kind === "create"
197
218
  ? `šŸ“¦ New pack: ${r.name}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`
198
219
  : `āœļø ${r.name} — ${clipWords(r.note, 180)}`);
@@ -535,15 +535,42 @@ function mergeMatches(primary, secondary, keyOf) {
535
535
  function logRecall(msg, candPacks, candEntities, keptPacks, keptEntities) {
536
536
  if (String(process.env.RECALL_DEBUG || "on").toLowerCase() === "off") return;
537
537
  try {
538
+ // FTS-miss instrumentation (spec #6): packs the relevance judge KEPT even
539
+ // though keyword FTS scored them ~0 (or they only surfaced via context, not
540
+ // the user's words) are pure-semantic rescues. If this count climbs, a
541
+ // vector backend earns its keep; until then it stays 1-line instrumentation.
542
+ const scoreOf = new Map(candPacks.map((m) => [m.dir, m]));
543
+ const ftsMissRescues = keptPacks
544
+ .map((m) => scoreOf.get(m.dir))
545
+ .filter((c) => c && (!c.score || c.origin === "context"))
546
+ .map((c) => c.dir);
538
547
  const rec = {
539
548
  ts: new Date().toISOString(),
540
549
  msg: String(msg || "").slice(0, 200),
541
550
  candidatePacks: candPacks.map((m) => ({ dir: m.dir, score: m.score, origin: m.origin })),
542
551
  keptPacks: keptPacks.map((m) => m.dir),
552
+ ftsMissRescues,
543
553
  candidateEntities: candEntities.map((m) => ({ slug: m.slug, score: m.score, origin: m.origin })),
544
554
  keptEntities: keptEntities.map((m) => m.slug),
545
555
  };
546
556
  fs.appendFileSync(path.join(CONFIG_DIR, "recall-debug.jsonl"), JSON.stringify(rec) + "\n");
557
+ bumpFtsMissCounter(ftsMissRescues.length);
558
+ } catch (e) {}
559
+ }
560
+
561
+ // Cumulative tally so we don't have to re-scan the whole JSONL to answer
562
+ // "is keyword recall missing semantic matches often enough to warrant vectors?"
563
+ // Counts every recall turn plus how many of them rescued an FTS-missed pack.
564
+ function bumpFtsMissCounter(n) {
565
+ try {
566
+ const p = path.join(CONFIG_DIR, "recall-stats.json");
567
+ let stats = {};
568
+ try { stats = JSON.parse(fs.readFileSync(p, "utf8")) || {}; } catch (e) {}
569
+ stats.turns = (stats.turns || 0) + 1;
570
+ if (n > 0) stats.turnsWithRescue = (stats.turnsWithRescue || 0) + 1;
571
+ stats.ftsMissRescues = (stats.ftsMissRescues || 0) + n;
572
+ stats.updatedAt = new Date().toISOString();
573
+ fs.writeFileSync(p, JSON.stringify(stats, null, 2));
547
574
  } catch (e) {}
548
575
  }
549
576
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.25",
3
+ "version": "2.6.26",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {