@inetafrica/open-claudia 3.3.1 → 3.4.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/CHANGELOG.md +21 -0
- package/channels/kazee/adapter.js +6 -0
- package/channels/spaces/adapter.js +11 -2
- package/channels/telegram/adapter.js +28 -9
- package/core/access.js +125 -80
- package/core/actions.js +58 -4
- package/core/approvals.js +6 -0
- package/core/config.js +2 -1
- package/core/context.js +26 -2
- package/core/enforcer.js +113 -3
- package/core/fsutil.js +42 -1
- package/core/grants.js +221 -0
- package/core/handlers.js +49 -0
- package/core/intro-flow.js +83 -17
- package/core/mandates.js +10 -1
- package/core/people.js +157 -134
- package/core/relationship.js +45 -4
- package/core/router.js +18 -7
- package/core/scheduler.js +23 -2
- package/core/state.js +77 -3
- package/package.json +2 -2
- package/setup.js +2 -5
- package/test-enforcer.js +31 -0
- package/test-mandate-hygiene.js +23 -5
- package/test-provider-gateway.js +1 -0
- package/test-provider-language.js +26 -1
- package/web.js +5 -2
package/core/enforcer.js
CHANGED
|
@@ -112,6 +112,35 @@ function loadTriageGrant(speaker, kind) {
|
|
|
112
112
|
} catch (e) { return ""; }
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// What this proposed output costs on the capability ladder. Talking (a reply,
|
|
116
|
+
// or the triage decision to engage at all) is the weakest rung; running
|
|
117
|
+
// something on the person's behalf is `act`, and a destructive tool run keeps
|
|
118
|
+
// its own rung so an `act` grant can never authorize one.
|
|
119
|
+
function neededCapability(kind, tier) {
|
|
120
|
+
if (kind === "reply" || kind === "triage") return "read";
|
|
121
|
+
return String(tier || "").toLowerCase() === "destructive" ? "destructive" : "act";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The deterministic gate, ahead of the model. A live (principal × room) grant is
|
|
125
|
+
// the owner's own decision and needs no judging — the model is advisory INSIDE a
|
|
126
|
+
// grant, never the thing that issues one. No grant is not a denial here: it just
|
|
127
|
+
// means nothing has been decided yet, so the model layers below still run and
|
|
128
|
+
// anything they don't clearly cover escalates to an owner as before.
|
|
129
|
+
function grantClearance(speaker, kind, tier) {
|
|
130
|
+
try {
|
|
131
|
+
if (!speaker || !speaker.speakerId || !speaker.room) return null;
|
|
132
|
+
const grants = require("./grants");
|
|
133
|
+
const decision = grants.allows({
|
|
134
|
+
adapter: speaker.adapterType,
|
|
135
|
+
senderId: speaker.speakerId,
|
|
136
|
+
room: speaker.room,
|
|
137
|
+
needed: neededCapability(kind, tier),
|
|
138
|
+
});
|
|
139
|
+
if (!decision.ok) return null;
|
|
140
|
+
return decision.grant;
|
|
141
|
+
} catch (e) { return null; }
|
|
142
|
+
}
|
|
143
|
+
|
|
115
144
|
// verdict cache: identical (kind, relationship, layers, payload) → same answer
|
|
116
145
|
// within a short TTL, so a chatty external turn that re-checks the same reply
|
|
117
146
|
// doesn't re-bill the model (R9). Every policy layer (and the unverified flag)
|
|
@@ -173,6 +202,7 @@ function logVerdict(audit, context, result) {
|
|
|
173
202
|
cached: !!result.cached,
|
|
174
203
|
...(result.retried ? { retried: true } : {}),
|
|
175
204
|
...(result.code ? { code: result.code } : {}),
|
|
205
|
+
...(result.grantId ? { grantId: result.grantId } : {}),
|
|
176
206
|
});
|
|
177
207
|
}
|
|
178
208
|
|
|
@@ -201,6 +231,21 @@ function createEnforcerService(dependencies = {}) {
|
|
|
201
231
|
logVerdict(audit, auditContext, disabled);
|
|
202
232
|
return disabled;
|
|
203
233
|
}
|
|
234
|
+
const cleared = grantClearance(speaker, kind, tier);
|
|
235
|
+
if (cleared) {
|
|
236
|
+
const granted = {
|
|
237
|
+
decision: "allow",
|
|
238
|
+
reason: `covered by owner grant ${cleared.id} (${cleared.capability}${cleared.room === "*" ? ", all rooms" : ""})`,
|
|
239
|
+
...providerMetadata(null),
|
|
240
|
+
status: "granted",
|
|
241
|
+
grantId: cleared.id,
|
|
242
|
+
};
|
|
243
|
+
logVerdict(audit, auditContext, granted);
|
|
244
|
+
if (cleared.turnsRemaining != null) {
|
|
245
|
+
try { require("./grants").consumeTurn(cleared.id); } catch (e) {}
|
|
246
|
+
}
|
|
247
|
+
return granted;
|
|
248
|
+
}
|
|
204
249
|
const key = `${namespace}:${cacheKey({ kind, relationship, mandate, guardrails, channelRules, turnGrant, unverified, payload: clipped, tier })}`;
|
|
205
250
|
const hit = cacheGet(key);
|
|
206
251
|
if (hit) {
|
|
@@ -336,7 +381,7 @@ function esc(s) {
|
|
|
336
381
|
// Framing: the owner is deciding whether the bot should ACT on a request from
|
|
337
382
|
// this person — the card leads with WHO is asking WHAT, then what the bot
|
|
338
383
|
// proposes to do about it, never with the bot's output as the headline.
|
|
339
|
-
async function postOwnerButtons(rec, owner, { kind, summary, reason }) {
|
|
384
|
+
async function postOwnerButtons(rec, owner, { kind, summary, reason, strandedMandate = "", expiredNote = "" }) {
|
|
340
385
|
const adapter = adapterFor(owner.adapter);
|
|
341
386
|
if (!adapter) return false;
|
|
342
387
|
const who = rec.personName || "An external contact";
|
|
@@ -380,14 +425,27 @@ async function postOwnerButtons(rec, owner, { kind, summary, reason }) {
|
|
|
380
425
|
} catch (e) { /* card still posts without the verify affordance */ }
|
|
381
426
|
const lines = [head, ``, ...askBlock, ...body,
|
|
382
427
|
unverifiedLine ? `\n${unverifiedLine}` : ``,
|
|
428
|
+
strandedMandate ? `\n⚠️ ${esc(strandedMandate)}` : ``,
|
|
429
|
+
expiredNote ? `\n⏳ ${esc(expiredNote)}` : ``,
|
|
383
430
|
reason ? `\nWhy I'm checking: ${esc(reason)}` : ``];
|
|
384
431
|
const yes = kind === "triage" ? "✅ Yes, handle it" : "✅ Approve once";
|
|
385
432
|
const no = kind === "triage" ? "⛔ No, decline" : "⛔ Deny";
|
|
433
|
+
// Reach row: standing capability, and how far it travels. The default is
|
|
434
|
+
// deliberately the narrow one — a grant is for THIS person in THIS room, so
|
|
435
|
+
// trusting someone in one group never silently trusts them in another. Only
|
|
436
|
+
// offered when the record identifies the speaker apart from the room.
|
|
437
|
+
const reachRow = rec.speakerId && rec.room
|
|
438
|
+
? [
|
|
439
|
+
{ text: `✅ Always — here`, callback_data: `grn:${rec.id}:room` },
|
|
440
|
+
{ text: `🌍 Always — everywhere`, callback_data: `grn:${rec.id}:all` },
|
|
441
|
+
]
|
|
442
|
+
: null;
|
|
386
443
|
const keyboard = { inline_keyboard: [
|
|
387
444
|
[
|
|
388
445
|
{ text: yes, callback_data: `apr:${rec.id}:ok` },
|
|
389
446
|
{ text: no, callback_data: `apr:${rec.id}:no` },
|
|
390
447
|
],
|
|
448
|
+
...(reachRow ? [reachRow] : []),
|
|
391
449
|
[
|
|
392
450
|
{ text: "📝 Make a rule…", callback_data: `apr:${rec.id}:rule` },
|
|
393
451
|
...(verifyRow || []),
|
|
@@ -425,7 +483,12 @@ async function escalate({
|
|
|
425
483
|
const approvals = require("./approvals");
|
|
426
484
|
const relationship = require("./relationship");
|
|
427
485
|
const audit = safeAudit();
|
|
428
|
-
|
|
486
|
+
// Approval belongs to the owner SET. The card goes to every reachable owner;
|
|
487
|
+
// the first is recorded as the nominal approver only because the record has
|
|
488
|
+
// one such field — the apr: handler authorizes on isChatOwner, so any of them
|
|
489
|
+
// can decide, and decide() is idempotent so the second tap can't flip it.
|
|
490
|
+
const owners = await relationship.ownerTargets();
|
|
491
|
+
const owner = owners[0];
|
|
429
492
|
if (!owner) {
|
|
430
493
|
if (audit) audit.log("enforcer.escalate.failed", { reason: "no reachable owner", guardKind: kind, person: speaker && speaker.name });
|
|
431
494
|
return { ok: false, escalated: false, reason: "no reachable owner to approve" };
|
|
@@ -450,6 +513,49 @@ async function escalate({
|
|
|
450
513
|
channelLabel = String((await originAdapter.channelLabel(String(speaker.channelId))) || "").trim();
|
|
451
514
|
}
|
|
452
515
|
} catch (e) {}
|
|
516
|
+
// Dual-file trap detection: a mandate granted on the name-obvious note while
|
|
517
|
+
// the enforcer reads this person's (hashed) record slug silently does
|
|
518
|
+
// nothing. Detect it at the moment it bites — the escalation the owner
|
|
519
|
+
// thought a grant had already prevented — and say where the grant actually
|
|
520
|
+
// lives. Detection only: the stranded text is NEVER applied (names are
|
|
521
|
+
// spoofable; only the owner links the two, via verify).
|
|
522
|
+
let strandedMandate = "";
|
|
523
|
+
try {
|
|
524
|
+
if (speaker && speaker.person && speaker.slug && !String(speaker.mandate || "").trim()) {
|
|
525
|
+
const entities = require("./entities");
|
|
526
|
+
const sibling = entities.slugify(speaker.person.name || "");
|
|
527
|
+
if (sibling && sibling !== speaker.slug) {
|
|
528
|
+
const other = entities.readEntity(sibling);
|
|
529
|
+
const text = other && other.type === "person"
|
|
530
|
+
? require("./mandates").activeMandateText(String((other.sections && other.sections.Mandate) || "")).trim()
|
|
531
|
+
: "";
|
|
532
|
+
if (text) {
|
|
533
|
+
const people = require("./people");
|
|
534
|
+
const holder = people.list().find((p) => p.id !== speaker.person.id && people.resolveEntitySlug(p) === sibling);
|
|
535
|
+
if (!holder) {
|
|
536
|
+
strandedMandate = `A mandate exists on ${sibling}.md, but ${speaker.person.name}'s record reads ${speaker.slug}.md — those grants have NO effect. Merge them with the Verify button (or /people verify), or re-add via Make a rule.`;
|
|
537
|
+
if (audit) audit.log("enforcer.stranded-mandate", { personId: speaker.person.id, slug: speaker.slug, sibling });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch (e) { strandedMandate = ""; }
|
|
543
|
+
// A grant that simply ran out looks, from the owner's side, exactly like one
|
|
544
|
+
// they never gave — the bot just starts asking again. Say which line lapsed
|
|
545
|
+
// and when, so re-approving is an informed choice rather than a puzzle.
|
|
546
|
+
let expiredNote = "";
|
|
547
|
+
try {
|
|
548
|
+
if (speaker && speaker.slug) {
|
|
549
|
+
const entities = require("./entities");
|
|
550
|
+
const ent = entities.readEntity(speaker.slug);
|
|
551
|
+
const section = String((ent && ent.sections && ent.sections.Mandate) || "");
|
|
552
|
+
const lapsed = require("./mandates").expiredMandateLines(speaker.slug, section);
|
|
553
|
+
if (lapsed.length) {
|
|
554
|
+
const first = lapsed[0];
|
|
555
|
+
expiredNote = `A previous grant for ${personName || "them"} expired on ${first.expiresAt}: "${first.text.slice(0, 100)}"${lapsed.length > 1 ? ` (+${lapsed.length - 1} more)` : ""}. That is why I'm asking again.`;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} catch (e) { expiredNote = ""; }
|
|
453
559
|
const rec = approvals.create({
|
|
454
560
|
tool: kind === "reply" ? "external-reply" : kind === "triage" ? "external-triage" : "external-action",
|
|
455
561
|
verb: "",
|
|
@@ -479,8 +585,12 @@ async function escalate({
|
|
|
479
585
|
entitySlug: (speaker && speaker.slug) || "",
|
|
480
586
|
inboundText,
|
|
481
587
|
channelLabel,
|
|
588
|
+
speakerId: (speaker && speaker.speakerId) || "",
|
|
589
|
+
room: (speaker && speaker.room) || "",
|
|
482
590
|
});
|
|
483
|
-
const
|
|
591
|
+
const results = await Promise.all(owners.map((o) =>
|
|
592
|
+
postOwnerButtons(rec, o, { kind, summary, reason, strandedMandate, expiredNote }).catch(() => false)));
|
|
593
|
+
const posted = results.some(Boolean);
|
|
484
594
|
if (!posted) {
|
|
485
595
|
approvals.decide(rec.id, "denied", "owner-unreachable");
|
|
486
596
|
if (audit) audit.log("enforcer.escalate.failed", { reason: "could not post to owner", id: rec.id });
|
package/core/fsutil.js
CHANGED
|
@@ -52,4 +52,45 @@ function readJsonWithFallback(file, fallback) {
|
|
|
52
52
|
return fallback;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
// Cross-process mutex for read-modify-write on a shared JSON store. Atomic
|
|
56
|
+
// writes stop a file being torn in half, but they cannot stop two processes
|
|
57
|
+
// each reading the same list, each appending one entry, and the second rename
|
|
58
|
+
// discarding the first's. The bot and the `open-claudia` CLI are separate
|
|
59
|
+
// processes writing the same stores, so that race is reachable.
|
|
60
|
+
//
|
|
61
|
+
// An exclusive-create lockfile is the portable primitive (no flock on every
|
|
62
|
+
// filesystem we run on). A held lock older than LOCK_STALE_MS is assumed to
|
|
63
|
+
// belong to a process that died mid-write and is reclaimed, so a crash can
|
|
64
|
+
// never wedge the store permanently. Failing to acquire is not fatal: the
|
|
65
|
+
// caller proceeds unlocked, which is exactly today's behaviour.
|
|
66
|
+
const LOCK_STALE_MS = 10_000;
|
|
67
|
+
const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
68
|
+
|
|
69
|
+
function withFileLock(file, fn) {
|
|
70
|
+
const lock = `${file}.lock`;
|
|
71
|
+
let held = null;
|
|
72
|
+
for (let attempt = 0; attempt < 21 && !held; attempt += 1) {
|
|
73
|
+
try {
|
|
74
|
+
held = fs.openSync(lock, "wx", 0o600);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error.code !== "EEXIST") break;
|
|
77
|
+
try {
|
|
78
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
79
|
+
fs.rmSync(lock, { force: true });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
} catch (_) { continue; }
|
|
83
|
+
Atomics.wait(LOCK_WAIT, 0, 0, 5);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return fn();
|
|
88
|
+
} finally {
|
|
89
|
+
if (held !== null) {
|
|
90
|
+
try { fs.closeSync(held); } catch (_) {}
|
|
91
|
+
try { fs.rmSync(lock, { force: true }); } catch (_) {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { atomicWriteFileSync, readJsonWithFallback, withFileLock };
|
package/core/grants.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// (principal × room) capability grants — the only source of non-owner
|
|
2
|
+
// authority on this pod.
|
|
3
|
+
//
|
|
4
|
+
// The three external-policy layers that predate this file are each keyed by
|
|
5
|
+
// exactly one thing: global guardrails apply to every external person, channel
|
|
6
|
+
// rules to everyone in one room, a person's mandate to one person in every
|
|
7
|
+
// room. The pair — THIS person, in THIS room — had no home, so trusting
|
|
8
|
+
// someone in one group silently trusted them in every group they shared with
|
|
9
|
+
// the bot.
|
|
10
|
+
//
|
|
11
|
+
// Two boundaries this module must never cross:
|
|
12
|
+
// 1. Authority is pod-owned. Room membership and platform roles (a Kazee
|
|
13
|
+
// groupAdmin, a Telegram chat admin) confer nothing here. Whoever
|
|
14
|
+
// administers a room decides only whether the agent is IN it — an agent is
|
|
15
|
+
// a user, and that is exactly the power any member holds over any other.
|
|
16
|
+
// 2. Rooms are surface-agnostic. `<surface>:<id>` covers a Kazee group, a
|
|
17
|
+
// Spaces task thread, a Telegram group and a DM alike, so a pod that lives
|
|
18
|
+
// only on Spaces and never touches Kazee chat resolves grants identically.
|
|
19
|
+
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const { GRANTS_FILE } = require("./config");
|
|
22
|
+
const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
|
|
23
|
+
|
|
24
|
+
// Ordered weakest → strongest. A grant answers with one of these; the enforcer
|
|
25
|
+
// maps a proposed action onto the same ladder and refuses anything above.
|
|
26
|
+
const CAPABILITIES = ["read", "act", "destructive"];
|
|
27
|
+
|
|
28
|
+
const ALL_ROOMS = "*";
|
|
29
|
+
|
|
30
|
+
function nowIso() { return new Date().toISOString(); }
|
|
31
|
+
|
|
32
|
+
function capabilityRank(capability) {
|
|
33
|
+
const idx = CAPABILITIES.indexOf(String(capability || "").toLowerCase());
|
|
34
|
+
return idx < 0 ? -1 : idx;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeCapability(capability) {
|
|
38
|
+
const c = String(capability || "").toLowerCase();
|
|
39
|
+
return CAPABILITIES.includes(c) ? c : "read";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Rooms are keyed `<surface>:<id>`, matching core/identity.conversationKeyFor
|
|
43
|
+
// so a caller can hand us a conversation key directly.
|
|
44
|
+
function roomKey(surface, roomId) {
|
|
45
|
+
const s = String(surface || "").trim().toLowerCase();
|
|
46
|
+
const r = String(roomId || "").trim();
|
|
47
|
+
if (!s || !r) return "";
|
|
48
|
+
return `${s}:${r}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function principalKey(adapter, senderId) {
|
|
52
|
+
const a = String(adapter || "").trim().toLowerCase();
|
|
53
|
+
const s = String(senderId || "").trim();
|
|
54
|
+
if (!a || !s) return "";
|
|
55
|
+
return `${a}:${s}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function load() {
|
|
59
|
+
const raw = readJsonWithFallback(GRANTS_FILE, null);
|
|
60
|
+
const list = raw && Array.isArray(raw.grants) ? raw.grants : [];
|
|
61
|
+
return list.filter((g) => g && typeof g === "object");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function save(grants) {
|
|
65
|
+
atomicWriteFileSync(GRANTS_FILE, JSON.stringify({ grants }, null, 2), { backup: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isLive(grant, at = Date.now()) {
|
|
69
|
+
if (!grant || grant.revokedAt) return false;
|
|
70
|
+
if (grant.expiresAt && Date.parse(grant.expiresAt) <= at) return false;
|
|
71
|
+
if (grant.turnsRemaining != null && Number(grant.turnsRemaining) <= 0) return false;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The deterministic lookup that replaces the LLM as the gate. Returns the
|
|
76
|
+
// strongest live grant for this principal in this room, preferring a grant
|
|
77
|
+
// issued for this exact room over an all-rooms grant, so a narrow room grant
|
|
78
|
+
// can never be weakened by a broad one and vice versa.
|
|
79
|
+
function resolve({ adapter, senderId, room }) {
|
|
80
|
+
const principal = principalKey(adapter, senderId);
|
|
81
|
+
const target = String(room || "");
|
|
82
|
+
if (!principal) return null;
|
|
83
|
+
const at = Date.now();
|
|
84
|
+
const candidates = load().filter((g) =>
|
|
85
|
+
isLive(g, at)
|
|
86
|
+
&& principalKey(g.principal?.adapter, g.principal?.channelId) === principal
|
|
87
|
+
&& (g.room === target || g.room === ALL_ROOMS));
|
|
88
|
+
if (!candidates.length) return null;
|
|
89
|
+
candidates.sort((a, b) => {
|
|
90
|
+
// Exact room beats all-rooms; then the stronger capability wins.
|
|
91
|
+
const exact = (b.room === target) - (a.room === target);
|
|
92
|
+
if (exact) return exact;
|
|
93
|
+
return capabilityRank(b.capability) - capabilityRank(a.capability);
|
|
94
|
+
});
|
|
95
|
+
return candidates[0];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// True when this principal may take an action at `needed` strength in `room`.
|
|
99
|
+
// Owners never reach here — they are resolved before any grant lookup.
|
|
100
|
+
function allows({ adapter, senderId, room, needed = "read", subject = null }) {
|
|
101
|
+
const grant = resolve({ adapter, senderId, room });
|
|
102
|
+
if (!grant) return { ok: false, reason: "no_grant", grant: null };
|
|
103
|
+
if (capabilityRank(grant.capability) < capabilityRank(needed)) {
|
|
104
|
+
return { ok: false, reason: "capability_too_low", grant };
|
|
105
|
+
}
|
|
106
|
+
const scope = Array.isArray(grant.scope) ? grant.scope : [];
|
|
107
|
+
// An empty scope means "anything within this capability" — a scoped grant
|
|
108
|
+
// only covers the subjects it names.
|
|
109
|
+
if (scope.length && subject && !scope.includes(String(subject))) {
|
|
110
|
+
return { ok: false, reason: "out_of_scope", grant };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, reason: "granted", grant };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Only an owner may call this — enforced by the caller, which holds the turn
|
|
116
|
+
// context needed to know who is asking.
|
|
117
|
+
function grant({
|
|
118
|
+
adapter,
|
|
119
|
+
senderId,
|
|
120
|
+
room,
|
|
121
|
+
capability = "read",
|
|
122
|
+
scope = [],
|
|
123
|
+
grantedBy = "",
|
|
124
|
+
personId = null,
|
|
125
|
+
expiresAt = null,
|
|
126
|
+
turns = null,
|
|
127
|
+
allRooms = false,
|
|
128
|
+
}) {
|
|
129
|
+
const principal = principalKey(adapter, senderId);
|
|
130
|
+
if (!principal) throw new Error("adapter and senderId are required");
|
|
131
|
+
const target = allRooms ? ALL_ROOMS : String(room || "");
|
|
132
|
+
if (!target) throw new Error("room is required unless allRooms is set");
|
|
133
|
+
const grants = load();
|
|
134
|
+
// Re-granting the same pair replaces rather than stacks, so revoking one
|
|
135
|
+
// record can never leave a weaker duplicate behind still granting access.
|
|
136
|
+
const kept = grants.filter((g) =>
|
|
137
|
+
!(principalKey(g.principal?.adapter, g.principal?.channelId) === principal
|
|
138
|
+
&& g.room === target
|
|
139
|
+
&& isLive(g)));
|
|
140
|
+
const record = {
|
|
141
|
+
id: `grant_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
142
|
+
principal: { adapter: String(adapter).toLowerCase(), channelId: String(senderId) },
|
|
143
|
+
personId: personId || null,
|
|
144
|
+
room: target,
|
|
145
|
+
capability: normalizeCapability(capability),
|
|
146
|
+
scope: Array.isArray(scope) ? scope.map(String) : [],
|
|
147
|
+
grantedBy: String(grantedBy || ""),
|
|
148
|
+
grantedAt: nowIso(),
|
|
149
|
+
expiresAt: expiresAt || null,
|
|
150
|
+
turnsRemaining: turns == null ? null : Number(turns),
|
|
151
|
+
revokedAt: null,
|
|
152
|
+
};
|
|
153
|
+
kept.push(record);
|
|
154
|
+
save(kept);
|
|
155
|
+
return record;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function revoke(grantId) {
|
|
159
|
+
const grants = load();
|
|
160
|
+
const g = grants.find((x) => x.id === grantId);
|
|
161
|
+
if (!g || g.revokedAt) return null;
|
|
162
|
+
g.revokedAt = nowIso();
|
|
163
|
+
save(grants);
|
|
164
|
+
return g;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Revoke every live grant for a principal, optionally only in one room.
|
|
168
|
+
function revokeAllFor({ adapter, senderId, room = null }) {
|
|
169
|
+
const principal = principalKey(adapter, senderId);
|
|
170
|
+
if (!principal) return 0;
|
|
171
|
+
const grants = load();
|
|
172
|
+
let n = 0;
|
|
173
|
+
for (const g of grants) {
|
|
174
|
+
if (!isLive(g)) continue;
|
|
175
|
+
if (principalKey(g.principal?.adapter, g.principal?.channelId) !== principal) continue;
|
|
176
|
+
if (room && g.room !== room) continue;
|
|
177
|
+
g.revokedAt = nowIso();
|
|
178
|
+
n += 1;
|
|
179
|
+
}
|
|
180
|
+
if (n) save(grants);
|
|
181
|
+
return n;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// One-turn grants burn down as they are used, so an owner's "just this once"
|
|
185
|
+
// can't linger as standing access.
|
|
186
|
+
function consumeTurn(grantId) {
|
|
187
|
+
const grants = load();
|
|
188
|
+
const g = grants.find((x) => x.id === grantId);
|
|
189
|
+
if (!g || g.turnsRemaining == null) return null;
|
|
190
|
+
g.turnsRemaining = Math.max(0, Number(g.turnsRemaining) - 1);
|
|
191
|
+
save(grants);
|
|
192
|
+
return g;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function list({ includeExpired = false } = {}) {
|
|
196
|
+
const all = load();
|
|
197
|
+
return includeExpired ? all : all.filter((g) => isLive(g));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function listForRoom(room, { includeExpired = false } = {}) {
|
|
201
|
+
return list({ includeExpired }).filter((g) => g.room === String(room) || g.room === ALL_ROOMS);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
CAPABILITIES,
|
|
206
|
+
ALL_ROOMS,
|
|
207
|
+
GRANTS_FILE,
|
|
208
|
+
roomKey,
|
|
209
|
+
principalKey,
|
|
210
|
+
capabilityRank,
|
|
211
|
+
normalizeCapability,
|
|
212
|
+
isLive,
|
|
213
|
+
resolve,
|
|
214
|
+
allows,
|
|
215
|
+
grant,
|
|
216
|
+
revoke,
|
|
217
|
+
revokeAllFor,
|
|
218
|
+
consumeTurn,
|
|
219
|
+
list,
|
|
220
|
+
listForRoom,
|
|
221
|
+
};
|
package/core/handlers.js
CHANGED
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
getProviderSettings,
|
|
21
21
|
getProjectSessions, getLastProjectSession, userOwnsProviderSession,
|
|
22
22
|
createSessionCallbackToken, linkIdentity,
|
|
23
|
+
roomSettingsFor, setRoomSetting,
|
|
23
24
|
} = require("./state");
|
|
24
25
|
const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities, isConfiguredOwnerChannel, removeIdentityMapping } = require("./identity");
|
|
25
26
|
const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
|
|
@@ -208,6 +209,13 @@ function switchProviderState(providerId, options = {}) {
|
|
|
208
209
|
providerSettings: structuredClone(state.providerSettings || {}),
|
|
209
210
|
isFirstMessage: state.isFirstMessage,
|
|
210
211
|
};
|
|
212
|
+
// The room is authoritative for who answers in it; the speaker's own value
|
|
213
|
+
// stays updated as their default for rooms they have never pinned. Without
|
|
214
|
+
// the room write, switching provider only moved the switcher — everyone else
|
|
215
|
+
// in the room, and every job it spawned, stayed on the old one.
|
|
216
|
+
const roomKey = state.roomKey ? String(state.roomKey) : "";
|
|
217
|
+
const previousRoomBackend = roomKey ? (roomSettingsFor(roomKey)?.backend ?? null) : null;
|
|
218
|
+
if (roomKey) setRoomSetting(roomKey, "backend", provider);
|
|
211
219
|
state.settings.backend = provider;
|
|
212
220
|
if (Object.prototype.hasOwnProperty.call(options, "model")) state.settings.model = options.model;
|
|
213
221
|
const project = getProjectKey(state);
|
|
@@ -220,6 +228,7 @@ function switchProviderState(providerId, options = {}) {
|
|
|
220
228
|
state.activeSessions = previous.activeSessions;
|
|
221
229
|
state.providerSettings = previous.providerSettings;
|
|
222
230
|
state.settings.backend = previous.provider;
|
|
231
|
+
if (roomKey) setRoomSetting(roomKey, "backend", previousRoomBackend);
|
|
223
232
|
state.isFirstMessage = previous.isFirstMessage;
|
|
224
233
|
});
|
|
225
234
|
return persisted.ok
|
|
@@ -1815,6 +1824,46 @@ register({
|
|
|
1815
1824
|
},
|
|
1816
1825
|
});
|
|
1817
1826
|
|
|
1827
|
+
register({
|
|
1828
|
+
name: "grants", description: "View/revoke capability grants (who may do what, in which room)", args: "[revoke <id> | revoke-all <adapter>:<sender>]", ownerOnly: true,
|
|
1829
|
+
handler: async (env, { tail }) => {
|
|
1830
|
+
if (!ownerEnv(env)) return send("Owner only — grants are restricted.");
|
|
1831
|
+
const grants = require("./grants");
|
|
1832
|
+
const trimmed = (tail || "").trim();
|
|
1833
|
+
const [sub, ...restArr] = trimmed ? trimmed.split(/\s+/) : [];
|
|
1834
|
+
const subCmd = (sub || "").toLowerCase();
|
|
1835
|
+
|
|
1836
|
+
if (subCmd === "revoke") {
|
|
1837
|
+
const id = restArr.join(" ").trim();
|
|
1838
|
+
if (!id) return send("Usage: /grants revoke <id>");
|
|
1839
|
+
const g = grants.revoke(id);
|
|
1840
|
+
if (!g) return send(`No live grant with id ${id}.`);
|
|
1841
|
+
try { require("./audit").log("grant.revoked", { id: g.id, room: g.room, capability: g.capability }); } catch (e) {}
|
|
1842
|
+
return send(`Revoked ${g.id} — ${g.capability} in ${g.room === grants.ALL_ROOMS ? "every room" : g.room}.`);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if (subCmd === "revoke-all") {
|
|
1846
|
+
const target = restArr.join(" ").trim();
|
|
1847
|
+
const idx = target.lastIndexOf(":");
|
|
1848
|
+
if (idx <= 0) return send("Usage: /grants revoke-all <adapter>:<senderId>");
|
|
1849
|
+
const n = grants.revokeAllFor({ adapter: target.slice(0, idx), senderId: target.slice(idx + 1) });
|
|
1850
|
+
return send(n ? `Revoked ${n} grant${n === 1 ? "" : "s"} for ${target}.` : `No live grants for ${target}.`);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
const live = grants.list();
|
|
1854
|
+
if (live.length === 0) {
|
|
1855
|
+
return send("No capability grants yet.\n\nA grant is what lets a non-owner act: it covers ONE person in ONE room, so trusting someone in one group never trusts them elsewhere. Make one from an approval card (\"Always — here\" / \"Always — everywhere\").");
|
|
1856
|
+
}
|
|
1857
|
+
const lines = live.map((g) => {
|
|
1858
|
+
const where = g.room === grants.ALL_ROOMS ? "everywhere" : g.room;
|
|
1859
|
+
const until = g.expiresAt ? ` · until ${g.expiresAt.slice(0, 10)}` : "";
|
|
1860
|
+
const turns = g.turnsRemaining != null ? ` · ${g.turnsRemaining} turn(s) left` : "";
|
|
1861
|
+
return `🔑 [${g.id}] ${g.principal.adapter}:${g.principal.channelId} → ${g.capability} in ${where}${until}${turns}`;
|
|
1862
|
+
});
|
|
1863
|
+
return send(`Capability grants (${live.length}):\n\n${lines.join("\n")}\n\n/grants revoke <id> · /grants revoke-all <adapter>:<senderId>\nFile: ${grants.GRANTS_FILE}`);
|
|
1864
|
+
},
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1818
1867
|
register({
|
|
1819
1868
|
name: "ideas", description: "View/manage the self-improvement backlog", args: "[add <idea> | remove <id>]",
|
|
1820
1869
|
handler: async (env, { tail }) => {
|
package/core/intro-flow.js
CHANGED
|
@@ -7,7 +7,7 @@ const people = require("./people");
|
|
|
7
7
|
const intros = require("./intros");
|
|
8
8
|
const audit = require("./audit");
|
|
9
9
|
const registry = require("./adapter-registry");
|
|
10
|
-
const { bootstrapOwner,
|
|
10
|
+
const { bootstrapOwner, mutateAuth, updateAuthorizedChatEnv } = require("./access");
|
|
11
11
|
const { CHAT_ID } = require("./config");
|
|
12
12
|
|
|
13
13
|
const MAX_BIO_LEN = 500;
|
|
@@ -98,11 +98,81 @@ async function notifyOwnerAboutIntro(intro) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Legacy auth.json entry for a channel, created — or upgraded in place — so
|
|
102
|
+
// isChatAuthorized/isChatOwner agree with the people record. isOwner flows
|
|
103
|
+
// from people.isOwner: dropping it here is what demoted owners approved onto
|
|
104
|
+
// a second channel (isChatOwner reads this file, not the person).
|
|
105
|
+
function ensureAuthEntry(channelId, { name = null, username = null, isOwner = false } = {}) {
|
|
106
|
+
const id = String(channelId);
|
|
107
|
+
// Under the store lock: this is the write that sets isOwner, and an unlocked
|
|
108
|
+
// read-modify-write racing another approval would drop the promotion — the
|
|
109
|
+
// roster would look right to whoever wrote last and the owner would be
|
|
110
|
+
// guarded on their own channel.
|
|
111
|
+
return mutateAuth((auth) => {
|
|
112
|
+
const existing = auth.authorized.find((a) => String(a.chatId) === id);
|
|
113
|
+
if (existing) {
|
|
114
|
+
if (isOwner && existing.isOwner !== true) existing.isOwner = true;
|
|
115
|
+
return auth;
|
|
116
|
+
}
|
|
117
|
+
auth.authorized.push({
|
|
118
|
+
chatId: id,
|
|
119
|
+
name: name || "",
|
|
120
|
+
username: username || null,
|
|
121
|
+
isOwner: !!isOwner,
|
|
122
|
+
authorizedAt: new Date().toISOString(),
|
|
123
|
+
});
|
|
124
|
+
return auth;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A sender whose transport user id already maps to a person is never
|
|
129
|
+
// interrogated: the id is adapter-attested (the same trust level as the
|
|
130
|
+
// KAZEE_OWNER_USER_ID owner match) — unlike display names, which are
|
|
131
|
+
// spoofable and are never matched. Only 1:1 conversations are adopted: the
|
|
132
|
+
// adapter must attest surface "dm", because auth.json and isChatOwner are
|
|
133
|
+
// per-CHANNEL, so adopting a multi-party room would hand every member the
|
|
134
|
+
// sender's standing. No attestation, no adoption (fail closed).
|
|
135
|
+
function adoptKnownSender(envelope, adapter, channelId) {
|
|
136
|
+
if (envelope.surface !== "dm") return null;
|
|
137
|
+
const senderId = envelope.userId != null ? String(envelope.userId) : "";
|
|
138
|
+
if (!senderId || senderId === channelId) return null;
|
|
139
|
+
const person = people.findByHandle(adapter, senderId);
|
|
140
|
+
if (!person) return null;
|
|
141
|
+
try {
|
|
142
|
+
people.linkHandle(person.id, {
|
|
143
|
+
adapter, channelId,
|
|
144
|
+
displayName: displayNameFromEnvelope(envelope),
|
|
145
|
+
approvedBy: "auto:transport-id",
|
|
146
|
+
});
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return null; // handle conflict — leave the channel to the normal intro flow
|
|
149
|
+
}
|
|
150
|
+
ensureAuthEntry(channelId, {
|
|
151
|
+
name: person.name || displayNameFromEnvelope(envelope),
|
|
152
|
+
username: usernameFromEnvelope(envelope),
|
|
153
|
+
isOwner: person.isOwner === true,
|
|
154
|
+
});
|
|
155
|
+
const pending = intros.activeForChannel(adapter, channelId);
|
|
156
|
+
if (pending) intros.remove(pending.id);
|
|
157
|
+
audit.log("intro.auto-adopted", {
|
|
158
|
+
personId: person.id, adapter, channelId, senderId,
|
|
159
|
+
isOwner: person.isOwner === true,
|
|
160
|
+
resolvedIntroId: pending ? pending.id : null,
|
|
161
|
+
});
|
|
162
|
+
return person;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Returns true when the intro flow consumed the message (the router must not
|
|
166
|
+
// process it further); false ONLY when the sender was recognised by transport
|
|
167
|
+
// id and the channel auto-adopted — the router then handles the turn normally.
|
|
101
168
|
async function handleInbound(envelope, sendFn) {
|
|
102
169
|
intros.sweep();
|
|
103
170
|
const adapter = envAdapterType(envelope);
|
|
104
171
|
const channelId = String(envelope.channelId);
|
|
105
|
-
if (!adapter) return
|
|
172
|
+
if (!adapter) return true;
|
|
173
|
+
|
|
174
|
+
const known = adoptKnownSender(envelope, adapter, channelId);
|
|
175
|
+
if (known) return false;
|
|
106
176
|
|
|
107
177
|
let intro = intros.activeForChannel(adapter, channelId);
|
|
108
178
|
const text = String(envelope.text || "").trim();
|
|
@@ -200,7 +270,7 @@ async function handleInbound(envelope, sendFn) {
|
|
|
200
270
|
return true;
|
|
201
271
|
}
|
|
202
272
|
|
|
203
|
-
return
|
|
273
|
+
return true;
|
|
204
274
|
}
|
|
205
275
|
|
|
206
276
|
async function applyApproval(intro, by) {
|
|
@@ -227,20 +297,16 @@ async function applyApproval(intro, by) {
|
|
|
227
297
|
} else {
|
|
228
298
|
throw new Error("intro has no claim — owner approved a half-finished intro");
|
|
229
299
|
}
|
|
230
|
-
// Also write into auth.json so legacy isChatAuthorized + isChatOwner still
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
})
|
|
240
|
-
saveAuth(auth);
|
|
241
|
-
if (intro.adapter === "telegram") {
|
|
242
|
-
try { updateAuthorizedChatEnv(auth); } catch (e) {}
|
|
243
|
-
}
|
|
300
|
+
// Also write into auth.json so legacy isChatAuthorized + isChatOwner still
|
|
301
|
+
// work. isOwner mirrors the person record: an owner approved onto a new
|
|
302
|
+
// channel keeps owner status there (hardcoding false demoted Kazee owners).
|
|
303
|
+
const auth = ensureAuthEntry(intro.channelId, {
|
|
304
|
+
name: intro.displayName || person.name,
|
|
305
|
+
username: intro.username,
|
|
306
|
+
isOwner: person.isOwner === true,
|
|
307
|
+
});
|
|
308
|
+
if (intro.adapter === "telegram") {
|
|
309
|
+
try { updateAuthorizedChatEnv(auth); } catch (e) {}
|
|
244
310
|
}
|
|
245
311
|
intros.approve(intro.id, by);
|
|
246
312
|
audit.log("intro.approved", { introId: intro.id, personId: person.id, by });
|
package/core/mandates.js
CHANGED
|
@@ -88,6 +88,15 @@ function activeMandateText(sectionText, now) {
|
|
|
88
88
|
.join("\n");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// The lines that HAVE lapsed for one person. activeMandateText drops them
|
|
92
|
+
// silently by design (the judge must never see a dead grant), but silence is
|
|
93
|
+
// wrong for the owner: a tap-grant expiring after GRANT_TTL_DAYS looked
|
|
94
|
+
// identical to never having granted it, so the bot simply started asking again
|
|
95
|
+
// with nothing to explain why. Callers surface these at the moment they bite.
|
|
96
|
+
function expiredMandateLines(slug, sectionText, now) {
|
|
97
|
+
return parseMandate(slug, sectionText, now).filter((l) => l.expired);
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
function defaultGrantExpiry(now) {
|
|
92
101
|
if (!GRANT_TTL_DAYS) return null;
|
|
93
102
|
return utcDate((now === undefined ? Date.now() : now) + GRANT_TTL_DAYS * 24 * 60 * 60 * 1000);
|
|
@@ -155,6 +164,6 @@ function revokeLine(id) {
|
|
|
155
164
|
module.exports = {
|
|
156
165
|
GRANT_TTL_DAYS,
|
|
157
166
|
normalize, splitExpiry, formatLine, isExpired, lineId,
|
|
158
|
-
parseMandate, activeMandateText, defaultGrantExpiry, extractExpiry,
|
|
167
|
+
parseMandate, activeMandateText, expiredMandateLines, defaultGrantExpiry, extractExpiry,
|
|
159
168
|
listPersonMandates, revokeLine,
|
|
160
169
|
};
|