@inetafrica/open-claudia 2.11.0 → 2.13.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.
@@ -0,0 +1,170 @@
1
+ // The independent guardrail (Phase 3.3/3.4). The enforcer vets every outbound
2
+ // reply and every write/destructive action aimed at an EXTERNAL person against
3
+ // the owner-authored mandate, using an isolated model call it can never be
4
+ // talked out of. These tests stub that model (core/subagent) and the transport
5
+ // (core/adapter-registry) so we can pin the security-critical behaviour with no
6
+ // network: owner is a strict no-op, the guard fails CLOSED (error/unparseable →
7
+ // escalate, never cached), identical vets are cached once, and an escalation
8
+ // reuses the approvals plumbing with owner-as-approver / external-as-origin.
9
+
10
+ const assert = require("assert");
11
+ const fs = require("fs");
12
+ const os = require("os");
13
+ const path = require("path");
14
+
15
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "enforcer-"));
16
+ process.env.OPEN_CLAUDIA_TEST = "1";
17
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
18
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
19
+ process.env.APPROVALS_DIR = path.join(tmp, "approvals");
20
+
21
+ // ── stub the model (destructured at enforcer load) and the transport (lazily
22
+ // required in adapterFor) BEFORE requiring the enforcer. ──────────────────
23
+ let subagentCalls = 0;
24
+ let lastOpts = null;
25
+ let subagentResponder = async () => ({ text: '{"decision":"allow","reason":"ok"}' });
26
+ const subagentPath = require.resolve("./core/subagent");
27
+ require.cache[subagentPath] = {
28
+ id: subagentPath, filename: subagentPath, loaded: true,
29
+ exports: {
30
+ spawnSubagent: async (prompt, opts) => { subagentCalls++; lastOpts = opts; return subagentResponder(prompt, opts); },
31
+ concurrentCount: () => 0,
32
+ },
33
+ };
34
+
35
+ const sends = []; // { channelId, text, opts }
36
+ const fakeAdapter = {
37
+ type: "telegram", id: "telegram",
38
+ send: async (channelId, text, opts) => { sends.push({ channelId: String(channelId), text, opts: opts || {} }); return { message_id: sends.length }; },
39
+ };
40
+ const registryPath = require.resolve("./core/adapter-registry");
41
+ require.cache[registryPath] = {
42
+ id: registryPath, filename: registryPath, loaded: true,
43
+ exports: { findAdapter: (id) => (id === "telegram" ? fakeAdapter : null), getAdapters: () => [fakeAdapter] },
44
+ };
45
+
46
+ const people = require("./core/people");
47
+ const entities = require("./core/entities");
48
+ const approvals = require("./core/approvals");
49
+ const relationship = require("./core/relationship");
50
+ const enforcer = require("./core/enforcer");
51
+ const { runInChat } = require("./core/context");
52
+
53
+ const adapter = { id: "telegram", type: "telegram" };
54
+ const CH_OWNER = "111";
55
+ const CH_EXT = "222";
56
+ const inChat = (channelId, fn) => runInChat({ adapter, channelId, transport: "telegram" }, fn);
57
+
58
+ // ── seed owner + one external with a narrow mandate ──
59
+ const owner = people.add({ name: "Ada Owner", isOwner: true });
60
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: CH_OWNER });
61
+ const ext = people.add({ name: "Zephyr Contact" });
62
+ people.linkHandle(ext.id, { adapter: "telegram", channelId: CH_EXT });
63
+ entities.upsertEntity({
64
+ name: "Zephyr Contact", type: "person", relationship: "external",
65
+ mandate: "May only discuss invoicing status.",
66
+ });
67
+ const extSpeaker = relationship.speakerFor("telegram", CH_EXT);
68
+
69
+ (async () => {
70
+ // ── enabled(): default on; ENFORCER=off flips it; env is read live ──
71
+ delete process.env.ENFORCER;
72
+ assert.strictEqual(enforcer.enabled(), true, "enforcer on by default");
73
+ process.env.ENFORCER = "off";
74
+ assert.strictEqual(enforcer.enabled(), false, "ENFORCER=off disables");
75
+ const disabled = await enforcer.vetReply(extSpeaker, "anything");
76
+ assert.strictEqual(disabled.decision, "allow", "disabled guard returns allow without a model call");
77
+ delete process.env.ENFORCER; // back to default-on for the rest
78
+
79
+ // ── owner is a strict no-op: guardOutboundReply allows without vetting ──
80
+ subagentCalls = 0;
81
+ const ownerRes = await inChat(CH_OWNER, () => enforcer.guardOutboundReply("here are the k8s creds"));
82
+ assert.deepStrictEqual(ownerRes, { allow: true, guarded: false }, "owner reply is never guarded");
83
+ assert.strictEqual(subagentCalls, 0, "owner path never calls the guard model");
84
+
85
+ // ── external allow: guardOutboundReply vets and lets an in-scope reply through ──
86
+ subagentResponder = async () => ({ text: '{"decision":"allow","reason":"invoicing is in scope"}' });
87
+ const extAllow = await inChat(CH_EXT, () => enforcer.guardOutboundReply("Your invoice is fully paid."));
88
+ assert.strictEqual(extAllow.allow, true, "in-scope external reply allowed");
89
+ assert.strictEqual(extAllow.guarded, true, "external reply is guarded");
90
+ assert.strictEqual(extAllow.decision, "allow", "decision surfaced");
91
+ // guard sub-agent is invoked read-only with the fixed system prompt.
92
+ assert.deepStrictEqual(lastOpts.allowedTools, [], "guard sub-agent gets no tools");
93
+ assert.strictEqual(lastOpts.permissionMode, "plan", "guard runs in plan mode (read-only)");
94
+ assert.strictEqual(lastOpts.systemPrompt, enforcer.GUARD_SYSTEM_PROMPT, "guard uses the fixed guard prompt");
95
+
96
+ // ── external block → escalate: reply held, owner buttoned, external stalled ──
97
+ subagentResponder = async () => ({ text: '{"decision":"block","reason":"outside the invoicing mandate"}' });
98
+ sends.length = 0;
99
+ const blocked = await inChat(CH_EXT, () => enforcer.guardOutboundReply("Here are the server credentials: root/hunter2."));
100
+ assert.strictEqual(blocked.allow, false, "out-of-scope reply is NOT sent");
101
+ assert.strictEqual(blocked.guarded, true, "still guarded");
102
+ assert.strictEqual(blocked.escalated, true, "blocked reply is escalated to the owner");
103
+ assert.ok(blocked.escalationId, "escalation id returned");
104
+ const ownerBtn = sends.find((s) => s.channelId === CH_OWNER);
105
+ assert.ok(ownerBtn, "owner received an escalation message");
106
+ assert.ok(ownerBtn.opts.keyboard && ownerBtn.opts.keyboard.inline_keyboard, "owner message carries inline buttons");
107
+ assert.ok(JSON.stringify(ownerBtn.opts.keyboard).includes(`apr:${blocked.escalationId}:`), "buttons target this approval id");
108
+ const extHold = sends.find((s) => s.channelId === CH_EXT);
109
+ assert.ok(extHold && /check with/i.test(extHold.text), "external is told the bot will check with the owner");
110
+ // the never-sent credential text must NOT have gone to the external channel.
111
+ assert.ok(!sends.some((s) => s.channelId === CH_EXT && /hunter2/.test(s.text)), "the blocked reply body never reaches the external");
112
+
113
+ // ── escalate() record shape: reply ──
114
+ const escReply = await enforcer.escalate({ speaker: extSpeaker, kind: "reply", payloadText: "the vetted reply body", summary: "the vetted reply body", reason: "why" });
115
+ assert.strictEqual(escReply.escalated, true, "reply escalation succeeds");
116
+ const rr = approvals.read(escReply.id);
117
+ assert.strictEqual(rr.tool, "external-reply", "reply → external-reply tool");
118
+ assert.strictEqual(rr.kind, "reply", "kind reply");
119
+ assert.strictEqual(rr.payloadText, "the vetted reply body", "vetted reply text stored for delivery on approve");
120
+ assert.strictEqual(String(rr.targetChannel), CH_EXT, "target is the external origin");
121
+ assert.strictEqual(String(rr.approverChannel), CH_OWNER, "approver is the owner");
122
+ assert.strictEqual(rr.personName, "Zephyr Contact", "person recorded for Always-allow");
123
+
124
+ // ── escalate() record shape: destructive action ──
125
+ const escAct = await enforcer.escalate({ speaker: extSpeaker, kind: "action", command: "open-claudia tool run fleet restart n1", tier: "destructive", reason: "not in mandate" });
126
+ assert.strictEqual(escAct.escalated, true, "action escalation succeeds");
127
+ const ar = approvals.read(escAct.id);
128
+ assert.strictEqual(ar.tool, "external-action", "action → external-action tool");
129
+ assert.strictEqual(ar.kind, "action", "kind action");
130
+ assert.strictEqual(ar.tier, "destructive", "action tier preserved");
131
+ assert.ok(ar.command.includes("fleet restart n1"), "the exact command is stored");
132
+ assert.strictEqual(ar.payloadText, "", "an action carries no reply payload");
133
+
134
+ // ── vetAction allow ──
135
+ subagentResponder = async () => ({ text: '{"decision":"allow","reason":"listing is in scope"}' });
136
+ const va = await enforcer.vetAction(extSpeaker, { command: "open-claudia tool run kticket list", tier: "write" });
137
+ assert.strictEqual(va.decision, "allow", "in-scope action allowed");
138
+
139
+ // ── cache: two identical vets → the model is called exactly once ──
140
+ subagentCalls = 0;
141
+ subagentResponder = async () => ({ text: '{"decision":"allow","reason":"ok"}' });
142
+ const CACHE_P = "identical-cache-payload-xyz";
143
+ const c1 = await enforcer.vetReply(extSpeaker, CACHE_P);
144
+ const c2 = await enforcer.vetReply(extSpeaker, CACHE_P);
145
+ assert.strictEqual(c1.decision, "allow");
146
+ assert.strictEqual(c2.decision, "allow");
147
+ assert.strictEqual(subagentCalls, 1, "identical vet is cached — the guard model runs once");
148
+
149
+ // ── fail CLOSED and NOT cached: a thrown model call → escalate, re-judged next time ──
150
+ subagentCalls = 0;
151
+ const FC_P = "fail-closed-payload-abc";
152
+ subagentResponder = async () => { throw new Error("model unreachable"); };
153
+ const f1 = await enforcer.vetReply(extSpeaker, FC_P);
154
+ assert.strictEqual(f1.decision, "escalate", "thrown model → escalate (fail closed)");
155
+ assert.ok(/failing closed/.test(f1.reason), "fail-closed reason surfaced");
156
+ subagentResponder = async () => ({ text: '{"decision":"allow","reason":"now reachable"}' });
157
+ const f2 = await enforcer.vetReply(extSpeaker, FC_P);
158
+ assert.strictEqual(f2.decision, "allow", "the failure was NOT cached — it is re-judged");
159
+ assert.strictEqual(subagentCalls, 2, "both attempts reached the model");
160
+
161
+ // ── unparseable + invalid decision both fail closed to escalate ──
162
+ subagentResponder = async () => ({ text: "I'm sorry, I can't do that." });
163
+ const up = await enforcer.vetReply(extSpeaker, "no-json-payload-1");
164
+ assert.strictEqual(up.decision, "escalate", "no JSON in the verdict → escalate");
165
+ subagentResponder = async () => ({ text: '{"decision":"maybe","reason":"unsure"}' });
166
+ const iv = await enforcer.vetReply(extSpeaker, "bad-decision-payload-1");
167
+ assert.strictEqual(iv.decision, "escalate", "unknown decision value coerced to escalate");
168
+
169
+ console.log("enforcer OK");
170
+ })().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,126 @@
1
+ // Persona packs: the entity note upgraded into a per-person memory that shapes
2
+ // how the bot talks to someone. These tests lock in the additive contract (R7):
3
+ // an ordinary entity serializes byte-identically to before the upgrade, while a
4
+ // person entity gains Role/Style/Knows/Mandate sections + a relationship scalar
5
+ // that round-trip cleanly. They also pin the FTS rule that Mandate (policy) is
6
+ // NOT indexed while the descriptive persona sections are.
7
+
8
+ const assert = require("assert");
9
+ const fs = require("fs");
10
+ const os = require("os");
11
+ const path = require("path");
12
+
13
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "persona-packs-"));
14
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
15
+
16
+ const ent = require("./core/entities");
17
+
18
+ // ── normalizeRelationship: only the three known scalars survive ──
19
+ assert.strictEqual(ent.normalizeRelationship("owner"), "owner", "owner passes");
20
+ assert.strictEqual(ent.normalizeRelationship(" Trusted "), "trusted", "trimmed + lowercased");
21
+ assert.strictEqual(ent.normalizeRelationship("EXTERNAL"), "external", "case-folded");
22
+ assert.strictEqual(ent.normalizeRelationship("stranger"), "", "unknown → empty");
23
+ assert.strictEqual(ent.normalizeRelationship(""), "", "empty → empty");
24
+ assert.strictEqual(ent.normalizeRelationship(null), "", "null → empty");
25
+
26
+ // ── R7: an ordinary (non-persona) entity serializes byte-identically ──
27
+ // No relationship line, no persona sections — just the historical Notes/Log
28
+ // frontmatter block. This is the exact shape entities had before the upgrade.
29
+ ent.writeEntity({
30
+ slug: "ordinary-co",
31
+ name: "Ordinary Co",
32
+ type: "org",
33
+ aliases: ["OC", "OrdCo"],
34
+ description: "an ordinary org",
35
+ relationship: "",
36
+ created: "2026-01-01T00:00:00.000Z",
37
+ updated: "2026-01-02T00:00:00.000Z",
38
+ last_seen: "2026-01-03T00:00:00.000Z",
39
+ sections: { Notes: "They do things.", Log: "- [2026-01-01] met them", Role: "", Style: "", Knows: "", Mandate: "" },
40
+ });
41
+ const ordinaryRaw = fs.readFileSync(path.join(process.env.ENTITIES_DIR, "ordinary-co.md"), "utf-8");
42
+ const expectedOrdinary = [
43
+ "---",
44
+ "name: Ordinary Co",
45
+ "type: org",
46
+ "aliases: OC, OrdCo",
47
+ "description: an ordinary org",
48
+ "created: 2026-01-01T00:00:00.000Z",
49
+ "updated: 2026-01-02T00:00:00.000Z",
50
+ "last_seen: 2026-01-03T00:00:00.000Z",
51
+ "---",
52
+ "",
53
+ "## Notes",
54
+ "",
55
+ "They do things.",
56
+ "",
57
+ "## Log",
58
+ "",
59
+ "- [2026-01-01] met them",
60
+ "",
61
+ ].join("\n");
62
+ assert.strictEqual(ordinaryRaw, expectedOrdinary, "ordinary entity is byte-identical to the pre-upgrade format");
63
+ assert.ok(!ordinaryRaw.includes("relationship:"), "no relationship line leaks into an ordinary entity");
64
+ assert.ok(!/##\s+(Role|Style|Knows|Mandate)/.test(ordinaryRaw), "no persona sections leak into an ordinary entity");
65
+
66
+ // ── a persona pack round-trips: relationship + all four sections survive ──
67
+ const person = ent.upsertEntity({
68
+ name: "Ada Owner",
69
+ type: "person",
70
+ relationship: "owner",
71
+ notes: "Runs the whole show.",
72
+ role: "Founder and primary operator.",
73
+ style: "Terse, technical, mobile-first. No hand-holding.",
74
+ knows: "Deep in the codebase; assume kubernetes and gitops fluency.",
75
+ mandate: "Full trust — no guardrails apply to the owner.",
76
+ log: "kicked off the persona work",
77
+ });
78
+ assert.strictEqual(person.created, true, "first upsert creates");
79
+ const ada = ent.findEntity("Ada Owner");
80
+ assert.strictEqual(ada.relationship, "owner", "relationship persisted");
81
+ assert.strictEqual(ada.sections.Role, "Founder and primary operator.", "Role round-trips");
82
+ assert.strictEqual(ada.sections.Style, "Terse, technical, mobile-first. No hand-holding.", "Style round-trips");
83
+ assert.strictEqual(ada.sections.Knows, "Deep in the codebase; assume kubernetes and gitops fluency.", "Knows round-trips");
84
+ assert.strictEqual(ada.sections.Mandate, "Full trust — no guardrails apply to the owner.", "Mandate round-trips");
85
+ assert.strictEqual(ada.sections.Notes, "Runs the whole show.", "Notes still work alongside persona");
86
+ assert.ok(ada.sections.Log.includes("kicked off the persona work"), "Log still appends");
87
+
88
+ const adaRaw = fs.readFileSync(path.join(process.env.ENTITIES_DIR, "ada-owner.md"), "utf-8");
89
+ assert.ok(adaRaw.includes("relationship: owner"), "relationship written to frontmatter");
90
+ assert.ok(adaRaw.includes("## Mandate"), "Mandate section written when populated");
91
+
92
+ // ── persona sections replace wholesale (like Notes), log still appends ──
93
+ const person2 = ent.upsertEntity({
94
+ name: "Ada Owner",
95
+ style: "Even terser now.",
96
+ log: "second observation",
97
+ });
98
+ assert.strictEqual(person2.created, false, "second upsert merges, not creates");
99
+ const ada2 = ent.findEntity("Ada Owner");
100
+ assert.strictEqual(ada2.sections.Style, "Even terser now.", "Style replaced wholesale");
101
+ assert.strictEqual(ada2.sections.Role, "Founder and primary operator.", "unspecified Role is left untouched");
102
+ assert.ok(ada2.sections.Log.includes("kicked off the persona work"), "old log line retained");
103
+ assert.ok(ada2.sections.Log.includes("second observation"), "new log line appended");
104
+
105
+ // ── relationship is validated on upsert: junk is ignored, not stored ──
106
+ ent.upsertEntity({ name: "Ada Owner", relationship: "banana" });
107
+ assert.strictEqual(ent.findEntity("Ada Owner").relationship, "owner", "invalid relationship does not overwrite a valid one");
108
+
109
+ // ── FTS: descriptive persona sections are matchable; Mandate (policy) is not ──
110
+ if (ent.reindex()) {
111
+ ent.upsertEntity({
112
+ name: "Zephyr Contact",
113
+ type: "person",
114
+ relationship: "external",
115
+ knows: "familiar with quantumfoo pipelines",
116
+ mandate: "may only discuss zebranaut invoicing, nothing else",
117
+ });
118
+ ent.reindex();
119
+ const byKnows = ent.matchEntities("quantumfoo", { threshold: 1 }).map((r) => r.slug);
120
+ assert.ok(byKnows.includes("zephyr-contact"), "a Knows word matches the persona pack");
121
+ const byMandate = ent.matchEntities("zebranaut", { threshold: 1 }).map((r) => r.slug);
122
+ assert.ok(!byMandate.includes("zephyr-contact"), "a Mandate word does NOT surface the pack (policy, not description)");
123
+ console.log("persona packs OK (incl. FTS mandate-exclusion)");
124
+ } else {
125
+ console.log("persona packs OK (FTS mandate-exclusion skipped — no node:sqlite)");
126
+ }
@@ -0,0 +1,79 @@
1
+ // Persona pipeline: the two write-side paths that POPULATE a persona pack —
2
+ // the post-turn reviewer (pack-review.applyEntityAction) and the nightly dream
3
+ // consolidation (dream.applyDream entity_notes). Both must thread the persona
4
+ // fields (relationship/role/style/knows/mandate) into the entity, replacing
5
+ // each section wholesale, without disturbing the base Notes/Log contract.
6
+
7
+ const assert = require("assert");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "persona-pipeline-"));
13
+ process.env.OPEN_CLAUDIA_TEST = "1";
14
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
15
+
16
+ const entities = require("./core/entities");
17
+ const review = require("./core/pack-review");
18
+ const dream = require("./core/dream");
19
+
20
+ // ── reviewer: a persona-bearing entity action writes all persona sections ──
21
+ const res = review.applyEntityAction({
22
+ name: "Rex Boss",
23
+ type: "person",
24
+ relationship: "trusted",
25
+ description: "the CTO",
26
+ role: "CTO — owns platform architecture.",
27
+ style: "Blunt and fast. Skip preamble.",
28
+ knows: "Deep backend; assume distributed-systems fluency.",
29
+ mandate: "May discuss roadmap and architecture, not headcount.",
30
+ log: "first mapped his persona",
31
+ });
32
+ assert.strictEqual(res.kind, "create", "reviewer creates the entity");
33
+ let rex = entities.findEntity("Rex Boss");
34
+ assert.strictEqual(rex.relationship, "trusted", "reviewer set relationship");
35
+ assert.strictEqual(rex.sections.Role, "CTO — owns platform architecture.", "reviewer set Role");
36
+ assert.strictEqual(rex.sections.Style, "Blunt and fast. Skip preamble.", "reviewer set Style");
37
+ assert.strictEqual(rex.sections.Knows, "Deep backend; assume distributed-systems fluency.", "reviewer set Knows");
38
+ assert.strictEqual(rex.sections.Mandate, "May discuss roadmap and architecture, not headcount.", "reviewer set Mandate");
39
+ assert.ok(rex.sections.Log.includes("first mapped his persona"), "reviewer appended Log");
40
+
41
+ // ── reviewer: a note-only action leaves persona sections intact ──
42
+ review.applyEntityAction({ name: "Rex Boss", notes: "Now leads a second team.", log: "team change" });
43
+ rex = entities.findEntity("Rex Boss");
44
+ assert.strictEqual(rex.sections.Notes, "Now leads a second team.", "notes updated");
45
+ assert.strictEqual(rex.sections.Style, "Blunt and fast. Skip preamble.", "persona Style untouched by a note-only update");
46
+ assert.strictEqual(rex.sections.Mandate, "May discuss roadmap and architecture, not headcount.", "persona Mandate untouched");
47
+
48
+ // ── dream: entity_notes consolidation can refine persona fields wholesale ──
49
+ const lines = dream.applyDream({
50
+ entity_notes: [{
51
+ entity: "rex-boss",
52
+ style: "Even blunter now — one-liners.",
53
+ mandate: "Roadmap, architecture AND hiring.",
54
+ }],
55
+ }, null);
56
+ rex = entities.findEntity("Rex Boss");
57
+ assert.strictEqual(rex.sections.Style, "Even blunter now — one-liners.", "dream refined Style wholesale");
58
+ assert.strictEqual(rex.sections.Mandate, "Roadmap, architecture AND hiring.", "dream refined Mandate wholesale");
59
+ assert.strictEqual(rex.sections.Role, "CTO — owns platform architecture.", "dream left unspecified Role intact");
60
+ assert.ok(lines.some((l) => /persona/.test(l)), "dream announces a persona refresh");
61
+
62
+ // ── dream: a plain note refresh still works and does NOT wipe persona ──
63
+ dream.applyDream({ entity_notes: [{ entity: "rex-boss", notes: "Back to one team." }] }, null);
64
+ rex = entities.findEntity("Rex Boss");
65
+ assert.strictEqual(rex.sections.Notes, "Back to one team.", "dream refreshed Notes");
66
+ assert.strictEqual(rex.sections.Style, "Even blunter now — one-liners.", "note-only dream refresh preserves persona Style");
67
+
68
+ // ── provenance (2.5): non-owner contributions are stamped, owner's are not ──
69
+ assert.strictEqual(review.provenanceTag({ name: "Zephyr Contact", isOwner: false }), "(via Zephyr Contact) ", "non-owner stamped");
70
+ assert.strictEqual(review.provenanceTag({ name: "Ada Owner", isOwner: true }), "", "owner not stamped (default author)");
71
+ assert.strictEqual(review.provenanceTag(null), "", "no source → no stamp");
72
+ assert.strictEqual(review.provenanceTag({ name: " ", isOwner: false }), "", "blank name → no stamp");
73
+ // The tag decorates the appended Log line (Rex already exists by now).
74
+ review.applyEntityAction({ name: "Rex Boss", log: "(via Zephyr Contact) flagged a billing question" });
75
+ const lastLog = entities.findEntity("Rex Boss").sections.Log.split("\n").pop();
76
+ assert.ok(lastLog.includes("(via Zephyr Contact) flagged a billing question"), "provenance tag lands in the Log line");
77
+ assert.ok(/^- \[\d{4}-\d{2}-\d{2}\] \(via Zephyr Contact\)/.test(lastLog), "date is prepended before the provenance tag");
78
+
79
+ console.log("persona pipeline OK");
@@ -0,0 +1,42 @@
1
+ // Relationship-gated cross-conversation recall (R6). Episodic recall reaches
2
+ // into the owner's project transcripts, so it must be OWNER-ONLY: an external
3
+ // speaker's turn must never pull the owner's past conversations. These tests
4
+ // pin the fail-closed gate — owner allowed, non-owner blocked, unknown blocked,
5
+ // and no-speaker-context (CLI/tests) ungated so local tooling still works.
6
+
7
+ const assert = require("assert");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-gate-"));
13
+ process.env.OPEN_CLAUDIA_TEST = "1";
14
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
15
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
16
+
17
+ const people = require("./core/people");
18
+ const { runInChat } = require("./core/context");
19
+ const { episodesAllowedForSpeaker } = require("./core/recall/discoverer");
20
+
21
+ const adapter = { id: "telegram", type: "telegram" };
22
+
23
+ const owner = people.add({ name: "Ada Owner", isOwner: true });
24
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: "111" });
25
+ const ext = people.add({ name: "Zephyr Contact" });
26
+ people.linkHandle(ext.id, { adapter: "telegram", channelId: "222" });
27
+
28
+ const inChat = (channelId, fn) => runInChat({ adapter, channelId, transport: "telegram" }, fn);
29
+
30
+ // No speaker context at all (CLI / tests) → ungated, episodes flow as before.
31
+ assert.strictEqual(episodesAllowedForSpeaker(), true, "no chat context → episodes allowed (CLI/tests)");
32
+
33
+ // Owner speaking → allowed.
34
+ assert.strictEqual(inChat("111", () => episodesAllowedForSpeaker()), true, "owner → episodes allowed");
35
+
36
+ // Known non-owner speaking → blocked (their turn can't pull the owner's past).
37
+ assert.strictEqual(inChat("222", () => episodesAllowedForSpeaker()), false, "external → episodes blocked");
38
+
39
+ // Unknown channel (no person record) → fail closed.
40
+ assert.strictEqual(inChat("999", () => episodesAllowedForSpeaker()), false, "unknown speaker → episodes blocked");
41
+
42
+ console.log("recall relationship gate OK");
@@ -0,0 +1,122 @@
1
+ // Relationship classification (Phase 3). The cheap, synchronous gate that
2
+ // decides how far the bot is CLEARED to go with whoever it's talking to. These
3
+ // tests pin the security invariants: the owner is owner ONLY via people.isOwner
4
+ // (a note can never confer it — R1), every unclassified / unmapped non-owner
5
+ // falls to "external" (default-deny), and no-context (CLI/cron/tests) stays
6
+ // ungated so the owner path is byte-for-byte unchanged when the guard is dormant.
7
+
8
+ const assert = require("assert");
9
+ const fs = require("fs");
10
+ const os = require("os");
11
+ const path = require("path");
12
+
13
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "relationship-"));
14
+ process.env.OPEN_CLAUDIA_TEST = "1";
15
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
16
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
17
+
18
+ const people = require("./core/people");
19
+ const entities = require("./core/entities");
20
+ const { runInChat } = require("./core/context");
21
+ const relationship = require("./core/relationship");
22
+
23
+ const adapter = { id: "telegram", type: "telegram" };
24
+ const CH_OWNER = "111";
25
+ const CH_OWNER_ALT = "1111";
26
+ const CH_EXT = "222";
27
+ const CH_TRUSTED = "333";
28
+ const CH_FAKE_OWNER = "444";
29
+ const CH_NO_PACK = "555";
30
+
31
+ // ── owner: two handles, primary is the second (alt) one ──
32
+ const owner = people.add({ name: "Ada Owner", isOwner: true });
33
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: CH_OWNER });
34
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: CH_OWNER_ALT });
35
+ people.setPrimary(owner.id, { adapter: "telegram", channelId: CH_OWNER_ALT });
36
+
37
+ // ── external: has a mandate that must surface ──
38
+ const ext = people.add({ name: "Zephyr Contact" });
39
+ people.linkHandle(ext.id, { adapter: "telegram", channelId: CH_EXT });
40
+ entities.upsertEntity({
41
+ name: "Zephyr Contact", type: "person", relationship: "external",
42
+ style: "Friendly but guarded.",
43
+ mandate: "May only discuss invoicing status.",
44
+ });
45
+
46
+ // ── trusted: relationship preserved (still non-owner → still guarded) ──
47
+ const trusted = people.add({ name: "Ravi Trusted" });
48
+ people.linkHandle(trusted.id, { adapter: "telegram", channelId: CH_TRUSTED });
49
+ entities.upsertEntity({ name: "Ravi Trusted", type: "person", relationship: "trusted" });
50
+
51
+ // ── a non-owner whose note CLAIMS relationship "owner" → must be coerced (R1) ──
52
+ const faker = people.add({ name: "Mallory Faker" });
53
+ people.linkHandle(faker.id, { adapter: "telegram", channelId: CH_FAKE_OWNER });
54
+ entities.upsertEntity({ name: "Mallory Faker", type: "person", relationship: "owner" });
55
+
56
+ // ── a mapped person with NO persona pack → default-deny to external ──
57
+ const bare = people.add({ name: "Bare Person" });
58
+ people.linkHandle(bare.id, { adapter: "telegram", channelId: CH_NO_PACK });
59
+
60
+ // ── speakerFor: owner ────────────────────────────────────────────────────────
61
+ const ownerSp = relationship.speakerFor("telegram", CH_OWNER);
62
+ assert.strictEqual(ownerSp.isOwner, true, "owner handle → isOwner");
63
+ assert.strictEqual(ownerSp.relationship, "owner", "owner relationship");
64
+ assert.strictEqual(ownerSp.mandate, "", "owner never carries a mandate (no guardrails)");
65
+ assert.strictEqual(relationship.guardActive(ownerSp), false, "owner is never guarded");
66
+
67
+ // ── speakerFor: external, mandate + style surfaced ───────────────────────────
68
+ const extSp = relationship.speakerFor("telegram", CH_EXT);
69
+ assert.strictEqual(extSp.isOwner, false, "external is not owner");
70
+ assert.strictEqual(extSp.relationship, "external", "external relationship");
71
+ assert.strictEqual(extSp.name, "Zephyr Contact", "external name resolved");
72
+ assert.strictEqual(extSp.mandate, "May only discuss invoicing status.", "external mandate surfaced");
73
+ assert.strictEqual(extSp.style, "Friendly but guarded.", "external style surfaced");
74
+ assert.strictEqual(relationship.guardActive(extSp), true, "external IS guarded");
75
+
76
+ // ── speakerFor: trusted preserved but still guarded ──────────────────────────
77
+ const trustedSp = relationship.speakerFor("telegram", CH_TRUSTED);
78
+ assert.strictEqual(trustedSp.relationship, "trusted", "trusted relationship preserved");
79
+ assert.strictEqual(relationship.guardActive(trustedSp), true, "trusted is non-owner → still guarded");
80
+
81
+ // ── R1: a note claiming "owner" on a non-owner is coerced to external ─────────
82
+ const fakerSp = relationship.speakerFor("telegram", CH_FAKE_OWNER);
83
+ assert.strictEqual(fakerSp.isOwner, false, "note cannot make a non-owner the owner");
84
+ assert.strictEqual(fakerSp.relationship, "external", "R1: relationship 'owner' coerced to external");
85
+ assert.strictEqual(relationship.guardActive(fakerSp), true, "coerced faker is guarded");
86
+
87
+ // ── mapped person, no pack → default-deny external ───────────────────────────
88
+ const bareSp = relationship.speakerFor("telegram", CH_NO_PACK);
89
+ assert.strictEqual(bareSp.relationship, "external", "no pack → default-deny external");
90
+ assert.strictEqual(bareSp.mandate, "", "no pack → empty mandate");
91
+
92
+ // ── unmapped channel (authed but not a known person) → external ──────────────
93
+ const strangerSp = relationship.speakerFor("telegram", "999999");
94
+ assert.strictEqual(strangerSp.isOwner, false, "unmapped channel is not owner");
95
+ assert.strictEqual(strangerSp.relationship, "external", "unmapped channel → external");
96
+ assert.strictEqual(relationship.guardActive(strangerSp), true, "unmapped channel is guarded");
97
+
98
+ // ── no context → null → ungated owner-equivalent (dormant guard) ─────────────
99
+ assert.strictEqual(relationship.speakerFor(null, null), null, "no adapter → null");
100
+ assert.strictEqual(relationship.speakerFor("telegram", ""), null, "no channel → null");
101
+ assert.strictEqual(relationship.guardActive(null), false, "null speaker is never guarded");
102
+
103
+ // ── currentSpeaker / isCurrentSpeakerGuarded via chat context ────────────────
104
+ const inChat = (channelId, fn) => runInChat({ adapter, channelId, transport: "telegram" }, fn);
105
+
106
+ assert.strictEqual(inChat(CH_OWNER, () => relationship.isCurrentSpeakerGuarded()), false,
107
+ "owner in-context is not guarded");
108
+ assert.strictEqual(inChat(CH_EXT, () => relationship.isCurrentSpeakerGuarded()), true,
109
+ "external in-context is guarded");
110
+ assert.strictEqual(inChat(CH_EXT, () => relationship.currentSpeaker().name), "Zephyr Contact",
111
+ "currentSpeaker resolves the in-context external");
112
+ // Outside any chat context there is no speaker → ungated.
113
+ assert.strictEqual(relationship.currentSpeaker(), null, "no chat context → null speaker");
114
+ assert.strictEqual(relationship.isCurrentSpeakerGuarded(), false, "no context → not guarded");
115
+
116
+ // ── ownerTarget resolves the owner's PRIMARY handle for escalation buttons ────
117
+ const target = relationship.ownerTarget();
118
+ assert.ok(target, "ownerTarget resolves an owner handle");
119
+ assert.strictEqual(target.adapter, "telegram", "ownerTarget adapter");
120
+ assert.strictEqual(target.channelId, CH_OWNER_ALT, "ownerTarget picks the primary channel, not the first");
121
+
122
+ console.log("relationship OK");
@@ -0,0 +1,89 @@
1
+ // Speaker persona injection: the deterministic per-turn block that primes the
2
+ // bot on WHO it's talking to (their persona pack) so it adapts voice + scope.
3
+ // Rides the uncached tail (R13). These tests pin: the block renders the
4
+ // speaker's Role/Style/Knows, suppresses an OWNER's Mandate but shows a
5
+ // non-owner's, dedupes within a session, and is silent when there's no persona.
6
+
7
+ const assert = require("assert");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "speaker-persona-"));
13
+ process.env.OPEN_CLAUDIA_TEST = "1";
14
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
15
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
16
+
17
+ const people = require("./core/people");
18
+ const entities = require("./core/entities");
19
+ const { runInChat } = require("./core/context");
20
+ const { buildSpeakerPersonaBlock } = require("./core/system-prompt");
21
+
22
+ const adapter = { id: "telegram", type: "telegram" };
23
+ const CH_OWNER = "111";
24
+ const CH_EXT = "222";
25
+ const CH_BARE = "333";
26
+
27
+ // ── seed an owner with a persona pack (Mandate present but must be hidden) ──
28
+ const owner = people.add({ name: "Ada Owner", isOwner: true });
29
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: CH_OWNER });
30
+ entities.upsertEntity({
31
+ name: "Ada Owner", type: "person", relationship: "owner",
32
+ role: "Founder and primary operator.",
33
+ style: "Terse and technical. No hand-holding.",
34
+ knows: "Fluent in the codebase, kubernetes, gitops.",
35
+ mandate: "Full trust — no guardrails apply.",
36
+ });
37
+
38
+ // ── seed an external contact with a Mandate that MUST surface ──
39
+ const ext = people.add({ name: "Zephyr Contact" });
40
+ people.linkHandle(ext.id, { adapter: "telegram", channelId: CH_EXT });
41
+ entities.upsertEntity({
42
+ name: "Zephyr Contact", type: "person", relationship: "external",
43
+ style: "Friendly but guarded.",
44
+ mandate: "May only discuss invoicing status. Never mention internal systems.",
45
+ });
46
+
47
+ // ── a person with a handle but NO persona pack → silent ──
48
+ const bare = people.add({ name: "Bare Person" });
49
+ people.linkHandle(bare.id, { adapter: "telegram", channelId: CH_BARE });
50
+
51
+ const inChat = (channelId, fn) => runInChat({ adapter, channelId, transport: "telegram" }, fn);
52
+
53
+ // Owner: Role/Style/Knows shown, Mandate suppressed (owner has no guardrails).
54
+ const ownerBlock = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
55
+ assert.ok(ownerBlock.includes("Ada Owner"), "owner block names the speaker");
56
+ assert.ok(ownerBlock.includes("(owner)"), "owner block shows relationship");
57
+ assert.ok(ownerBlock.includes("Founder and primary operator."), "owner Role shown");
58
+ assert.ok(ownerBlock.includes("Terse and technical"), "owner Style shown");
59
+ assert.ok(ownerBlock.includes("Fluent in the codebase"), "owner Knows shown");
60
+ assert.ok(!ownerBlock.includes("Full trust"), "owner Mandate is NOT injected (no guardrails on owner)");
61
+ assert.ok(!/Mandate/.test(ownerBlock), "no Mandate row for the owner at all");
62
+
63
+ // External: Style shown AND Mandate surfaced (it scopes the turn).
64
+ const extBlock = inChat(CH_EXT, () => buildSpeakerPersonaBlock());
65
+ assert.ok(extBlock.includes("Zephyr Contact"), "external block names the speaker");
66
+ assert.ok(extBlock.includes("(external)"), "external relationship shown");
67
+ assert.ok(extBlock.includes("Friendly but guarded."), "external Style shown");
68
+ assert.ok(extBlock.includes("May only discuss invoicing status"), "external Mandate IS injected");
69
+
70
+ // Bare person (handle, no pack): nothing to inject.
71
+ const bareBlock = inChat(CH_BARE, () => buildSpeakerPersonaBlock());
72
+ assert.strictEqual(bareBlock, "", "no persona pack → empty block");
73
+
74
+ // Unknown channel (no person): silent.
75
+ const strangerBlock = inChat("999", () => buildSpeakerPersonaBlock());
76
+ assert.strictEqual(strangerBlock, "", "unknown speaker → empty block");
77
+
78
+ // ── dedupe: within one session the same speaker is injected once ──
79
+ // (First call above already stamped CH_OWNER for session "new"; the repeat
80
+ // must come back empty so we don't re-bill the tail every turn.)
81
+ const ownerAgain = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
82
+ assert.strictEqual(ownerAgain, "", "same speaker re-injected within a session is deduped");
83
+
84
+ // ── a persona edit (new `updated` stamp) re-injects even in the same session ──
85
+ entities.upsertEntity({ name: "Ada Owner", style: "Even terser now." });
86
+ const ownerAfterEdit = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
87
+ assert.ok(ownerAfterEdit.includes("Even terser now."), "a persona edit re-injects the fresh block");
88
+
89
+ console.log("speaker persona OK");