@inetafrica/open-claudia 3.1.22 → 3.1.24

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,182 @@
1
+ // Scoped external rules (v3.1.23): the three-layer policy — global guardrails
2
+ // → per-channel rules → per-person mandate — plus auto-contacts and the
3
+ // meet-greet lane. These tests pin the security invariants:
4
+ // - channel rules are keyed generically on (adapterType, channelId) — any
5
+ // adapter participates by existing, no surface-specific logic;
6
+ // - auto-contacts NEVER link by display name (spoofable) — every new speaker
7
+ // gets a fresh record and a UNIQUE entity slug, so two same-named people
8
+ // can never share a mandate;
9
+ // - slug-keyed entity upserts land on exactly the record's entity;
10
+ // - the enforcer prompt carries all three layers and the UNVERIFIED marker,
11
+ // and its verdict cache busts when the channel layer changes.
12
+
13
+ const assert = require("assert");
14
+ const fs = require("fs");
15
+ const os = require("os");
16
+ const path = require("path");
17
+
18
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rule-scopes-"));
19
+ process.env.OPEN_CLAUDIA_TEST = "1";
20
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = tmp;
21
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
22
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
23
+ process.env.APPROVALS_DIR = path.join(tmp, "approvals");
24
+ process.env.CHANNEL_RULES_FILE = path.join(tmp, "channel-rules.md");
25
+
26
+ const cr = require("./core/channel-rules");
27
+ const people = require("./core/people");
28
+ const entities = require("./core/entities");
29
+ const relationship = require("./core/relationship");
30
+ const approvals = require("./core/approvals");
31
+ const { createEnforcerService } = require("./core/enforcer");
32
+
33
+ // ── channel-rules store ──────────────────────────────────────────────────────
34
+
35
+ {
36
+ const r1 = cr.addRule({ adapterType: "Kazee", channelId: "chat123", label: "Dev Team", text: "may share sprint status" });
37
+ assert.strictEqual(r1.added, true, "rule adds");
38
+ assert.strictEqual(r1.key, "kazee:chat123", "adapter type is lowercased in the key");
39
+
40
+ const dup = cr.addRule({ adapterType: "kazee", channelId: "chat123", text: "May share sprint status" });
41
+ assert.strictEqual(dup.added, false, "normalized duplicate is rejected");
42
+ assert.strictEqual(dup.duplicate, true);
43
+
44
+ const r2 = cr.addRule({ adapterType: "spaces", channelId: "task:t9", label: "Q3 Launch", text: "never discuss budgets" });
45
+ assert.strictEqual(r2.added, true, "second channel is independent");
46
+
47
+ assert.strictEqual(cr.listRules("kazee", "chat123").length, 1);
48
+ assert.strictEqual(cr.listRules("spaces", "task:t9").length, 1);
49
+ assert.strictEqual(cr.listRules("kazee", "otherchat").length, 0, "uncovered channel has no rules");
50
+ assert.strictEqual(cr.loadChannelRulesBlock("kazee", "otherchat"), "", "uncovered channel injects nothing");
51
+ assert.ok(cr.loadChannelRulesBlock("kazee", "chat123").includes("sprint status"));
52
+
53
+ // Rejections: empty, too long, missing channel.
54
+ assert.strictEqual(cr.addRule({ adapterType: "kazee", channelId: "chat123", text: " " }).added, false);
55
+ assert.strictEqual(cr.addRule({ adapterType: "kazee", channelId: "chat123", text: "x".repeat(cr.MAX_RULE_CHARS + 1) }).added, false);
56
+ assert.strictEqual(cr.addRule({ adapterType: "", channelId: "", text: "orphan rule" }).added, false);
57
+
58
+ // The file survives hand-edits: stray prose outside sections is ignored.
59
+ const raw = fs.readFileSync(cr.CHANNEL_RULES_FILE, "utf-8");
60
+ const parsed = cr.parseChannels("hand-written preamble\n" + raw + "\ntrailing note outside any section\n");
61
+ assert.strictEqual(parsed.length, 2, "hand-edited file still parses both sections");
62
+
63
+ // Removal: by id anywhere, and by exact text scoped to one channel.
64
+ assert.strictEqual(cr.removeRule(r1.id), true, "remove by id");
65
+ assert.strictEqual(cr.listRules("kazee", "chat123").length, 0);
66
+ assert.strictEqual(cr.removeRule("never discuss budgets", { adapterType: "spaces", channelId: "task:t9" }), true, "remove by text within scope");
67
+ assert.strictEqual(cr.rulesExist(), false, "store empty again");
68
+ }
69
+
70
+ // ── auto-contacts: fresh record, unique slug, never link-by-name ────────────
71
+
72
+ const envLucy = { adapter: { type: "kazee" }, channelId: "chatdoc1", userId: "user-lucy", from: { name: "Lucy Osweta" } };
73
+ const lucy = relationship.ensureContact(envLucy);
74
+ {
75
+ assert.ok(lucy, "auto-contact created");
76
+ assert.strictEqual(lucy.name, "Lucy Osweta");
77
+ assert.strictEqual(lucy.autoCreated, true, "marked auto-created (unverified)");
78
+ assert.ok(lucy.entitySlug && lucy.entitySlug !== "lucy-osweta", "entity slug is unique, not bare slugified name");
79
+ assert.ok(people.findByHandle("kazee", "user-lucy"), "handle linked to the sender id");
80
+
81
+ const ent = entities.readEntity(lucy.entitySlug);
82
+ assert.ok(ent, "entity created at the unique slug");
83
+ assert.strictEqual(ent.relationship, "external", "auto-contact is external (default-deny)");
84
+
85
+ assert.strictEqual(relationship.ensureContact(envLucy), null, "idempotent: second sighting is a no-op");
86
+ assert.strictEqual(people.list().length, 1, "no duplicate record");
87
+
88
+ // A same-named IMPOSTOR must get a fresh record and a different slug — never
89
+ // Lucy's mandate.
90
+ const impostor = relationship.ensureContact({ adapter: { type: "kazee" }, channelId: "chatdoc1", userId: "user-impostor", from: { name: "Lucy Osweta" } });
91
+ assert.ok(impostor && impostor.id !== lucy.id, "impostor gets their own record");
92
+ assert.ok(impostor.entitySlug !== lucy.entitySlug, "and their own entity");
93
+
94
+ // Slug-keyed mandate write lands ONLY on the real Lucy.
95
+ entities.upsertEntity({ slug: lucy.entitySlug, name: "Lucy Osweta", mandate: "- may ask about invoices" });
96
+ assert.strictEqual(entities.readEntity(lucy.entitySlug).sections.Mandate, "- may ask about invoices");
97
+ assert.strictEqual(entities.readEntity(impostor.entitySlug).sections.Mandate, "", "impostor inherits nothing");
98
+
99
+ // speakerFor resolves the mandate through the unique slug.
100
+ const spk = relationship.speakerFor("kazee", "chatdoc1", "user-lucy");
101
+ assert.strictEqual(spk.relationship, "external");
102
+ assert.strictEqual(spk.mandate, "- may ask about invoices");
103
+ assert.strictEqual(spk.slug, lucy.entitySlug);
104
+ assert.strictEqual(relationship.speakerFor("kazee", "chatdoc1", "user-impostor").mandate, "", "impostor speaker has no mandate");
105
+
106
+ // Known people and malformed envelopes never create records.
107
+ assert.strictEqual(relationship.ensureContact(envLucy), null);
108
+ assert.strictEqual(relationship.ensureContact({ channelId: "x" }), null, "no adapter → no-op");
109
+ assert.strictEqual(relationship.ensureContact({ adapter: { type: "kazee" } }), null, "no ids → no-op");
110
+
111
+ // Room-keyed envelope (telegram groups: userId === channelId is the ROOM,
112
+ // from.id is the real sender) must NOT create — it would mint one contact
113
+ // named after the first speaker and misattribute everyone after.
114
+ const before = people.list().length;
115
+ assert.strictEqual(relationship.ensureContact({ adapter: { type: "telegram" }, channelId: "-100", userId: "-100", from: { id: "555", name: "Group Speaker" } }), null, "from.id ≠ userId → coherence guard refuses");
116
+ assert.strictEqual(people.list().length, before, "no record minted for the room");
117
+
118
+ // A nameless sender still gets a usable record (meet-greet learns the name).
119
+ const ghost = relationship.ensureContact({ adapter: { type: "spaces" }, channelId: "task:t1", userId: "actor-42", from: {} });
120
+ assert.ok(ghost && ghost.name.startsWith("spaces user"), "fallback display name");
121
+
122
+ // Owner records are never auto-duplicated.
123
+ const owner = people.add({ name: "Ada Owner", isOwner: true });
124
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: "111" });
125
+ assert.strictEqual(relationship.ensureContact({ adapter: { type: "telegram" }, channelId: "111", userId: "111", from: { name: "Ada Owner" } }), null);
126
+ }
127
+
128
+ // ── approvals carry the channel label ───────────────────────────────────────
129
+
130
+ {
131
+ const rec = approvals.create({
132
+ tool: "external-reply", kind: "reply", channelId: "chatdoc1", adapter: "kazee",
133
+ targetAdapter: "kazee", targetChannel: "chatdoc1",
134
+ personName: "Lucy Osweta", channelLabel: "Dev Team",
135
+ });
136
+ assert.strictEqual(approvals.read(rec.id).channelLabel, "Dev Team", "channelLabel persists on the record");
137
+ const long = approvals.create({ tool: "external-reply", kind: "reply", channelLabel: "x".repeat(500) });
138
+ assert.strictEqual(approvals.read(long.id).channelLabel.length, 120, "channelLabel is clipped");
139
+ }
140
+
141
+ // ── enforcer: three layers in the prompt, unverified marker, cache busting ──
142
+
143
+ (async () => {
144
+ let calls = [];
145
+ const fakeRun = async (userPrompt) => {
146
+ calls.push(userPrompt);
147
+ return { json: { decision: "escalate", reason: "test" }, provider: "fake", model: "fake" };
148
+ };
149
+ const svc = createEnforcerService({ spawnUtilityAgent: fakeRun, cacheNamespace: "t1" });
150
+ const speaker = relationship.speakerFor("kazee", "chatdoc1", "user-lucy");
151
+
152
+ cr.addRule({ adapterType: "kazee", channelId: "chatdoc1", label: "Dev Team", text: "may share sprint status" });
153
+ await svc.verdict({ kind: "reply", speaker, payload: "Here is the sprint status." });
154
+ assert.strictEqual(calls.length, 1);
155
+ assert.ok(calls[0].includes("CHANNEL RULES"), "prompt has the channel layer");
156
+ assert.ok(calls[0].includes("may share sprint status"), "channel rule text is injected");
157
+ assert.ok(calls[0].includes("- may ask about invoices"), "mandate layer still injected");
158
+ assert.ok(calls[0].includes("(UNVERIFIED — brand-new auto-added contact)"), "auto-contact is flagged unverified");
159
+
160
+ // Identical inputs → cached, no second model call.
161
+ await svc.verdict({ kind: "reply", speaker, payload: "Here is the sprint status." });
162
+ assert.strictEqual(calls.length, 1, "verdict cache hit");
163
+
164
+ // A channel-rule edit busts the cache: same payload re-judged.
165
+ cr.addRule({ adapterType: "kazee", channelId: "chatdoc1", text: "never discuss salaries" });
166
+ await svc.verdict({ kind: "reply", speaker, payload: "Here is the sprint status." });
167
+ assert.strictEqual(calls.length, 2, "channel-rule change re-judges");
168
+ assert.ok(calls[1].includes("never discuss salaries"));
169
+
170
+ // A speaker on an uncovered channel gets the (none) placeholder — rules from
171
+ // other channels never leak in.
172
+ const elsewhere = { ...speaker, adapterType: "kazee", channelId: "otherchat" };
173
+ await svc.verdict({ kind: "reply", speaker: elsewhere, payload: "Hello." });
174
+ assert.ok(calls[2].includes("(none — the owner has set no rules for this channel)"), "no cross-channel leak");
175
+
176
+ // Guard prompt documents the union + meet-greet lane.
177
+ const enforcerSrc = fs.readFileSync(path.join(__dirname, "core", "enforcer.js"), "utf-8");
178
+ assert.ok(enforcerSrc.includes("UNION of the layers"), "system prompt unions all layers");
179
+ assert.ok(enforcerSrc.includes("Meet-and-greet lane"), "system prompt carries the meet-greet lane");
180
+
181
+ console.log("OK test-external-rule-scopes");
182
+ })().catch((e) => { console.error(e); process.exit(1); });
@@ -271,7 +271,38 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
271
271
  // uses a recent-page window), rendered Spaces-style with sender attribution,
272
272
  // so the chatter the gate stayed silent through is caught up exactly once:
273
273
  // the gate keeps the bot reply-silent, never context-blind.
274
- assert.strictEqual(pkg.version, "3.1.22", "release version must remain unchanged without explicit approval");
274
+ // 3.1.23 approved by Sumeet 2026-07-23 ("ok you building it in a extendable
275
+ // way for multi channels groups and public domains?"): scoped external rules —
276
+ // the make-a-rule card now offers three scopes (this person / this channel /
277
+ // everyone), with per-channel rules stored in channel-rules.md keyed
278
+ // generically on adapter:channelId so any surface participates by existing;
279
+ // the enforcer judges the UNION of security floor + global guardrails +
280
+ // channel rules + per-person mandate (any layer permits, any prohibition
281
+ // wins, default-deny) and busts its verdict cache on any layer edit;
282
+ // auto-contacts — the first authorized speaker with no people record gets a
283
+ // fresh one (never linked by spoofable display name; unique entity slug so
284
+ // same-named people can never share a mandate; room-keyed envelopes where
285
+ // from.id ≠ userId are refused so telegram groups never mint a misattributed
286
+ // contact) tagged autoCreated/unverified; meet-greet lane — brand-new
287
+ // unverified contacts get warm introductions (who they are, how they know the
288
+ // owner) without explicit coverage while disclosure, commitments, and actions
289
+ // stay held for escalation; approval cards say where the message came from
290
+ // via the optional adapter channelLabel (kazee chat name, spaces task title).
291
+ // 3.1.24 approved by Sumeet 2026-07-24 ("Okay, I think that makes sense, so go
292
+ // ahead and implement that and then push it out"): catalog-first recall — the
293
+ // proven 4-layer candidate-starvation (diag-recall-ranking.js: lexical seed
294
+ // scoring, junk-seed fire threshold, hub fan-out topK, and a dream-tuned
295
+ // walker cap of 10 vs exactly 10 seeds leaving graph-activated nodes ZERO
296
+ // slots since 2026-07-09) is fixed by inverting the contract: the walker's
297
+ // SYSTEM prompt now carries the ENTIRE pack/entity/tool index (one-liners,
298
+ // warm-session cached, exempt from the warm per-message char budget) so
299
+ // nothing is ever cut before a model sees it; lexical seeds + graph
300
+ // activation demote to per-turn ranking HINTS (top-K excerpts); the walker
301
+ // may keep ANY catalog id; cascade + tool pack-riding bypass under catalog
302
+ // mode; recall query mirrors it and the CLI banner reports honest
303
+ // hint-survival instead of raw expansion counts; catalog knob (dream-tunable,
304
+ // RECALL_CATALOG pin) restores the legacy menu.
305
+ assert.strictEqual(pkg.version, "3.1.24", "release version must remain unchanged without explicit approval");
275
306
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
276
307
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
277
308
  }