@inetafrica/open-claudia 3.3.2 → 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 +12 -0
- package/channels/kazee/adapter.js +1 -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 +42 -0
- package/core/approvals.js +6 -0
- package/core/config.js +2 -1
- package/core/context.js +26 -2
- package/core/enforcer.js +85 -3
- package/core/fsutil.js +42 -1
- package/core/grants.js +221 -0
- package/core/handlers.js +49 -0
- package/core/intro-flow.js +17 -16
- package/core/mandates.js +10 -1
- package/core/people.js +157 -134
- package/core/relationship.js +45 -4
- package/core/router.js +1 -0
- package/core/scheduler.js +23 -2
- package/core/state.js +77 -3
- package/package.json +2 -2
- package/setup.js +2 -5
- package/test-provider-gateway.js +1 -0
- package/test-provider-language.js +16 -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, strandedMandate = "" }) {
|
|
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";
|
|
@@ -381,14 +426,26 @@ async function postOwnerButtons(rec, owner, { kind, summary, reason, strandedMan
|
|
|
381
426
|
const lines = [head, ``, ...askBlock, ...body,
|
|
382
427
|
unverifiedLine ? `\n${unverifiedLine}` : ``,
|
|
383
428
|
strandedMandate ? `\n⚠️ ${esc(strandedMandate)}` : ``,
|
|
429
|
+
expiredNote ? `\n⏳ ${esc(expiredNote)}` : ``,
|
|
384
430
|
reason ? `\nWhy I'm checking: ${esc(reason)}` : ``];
|
|
385
431
|
const yes = kind === "triage" ? "✅ Yes, handle it" : "✅ Approve once";
|
|
386
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;
|
|
387
443
|
const keyboard = { inline_keyboard: [
|
|
388
444
|
[
|
|
389
445
|
{ text: yes, callback_data: `apr:${rec.id}:ok` },
|
|
390
446
|
{ text: no, callback_data: `apr:${rec.id}:no` },
|
|
391
447
|
],
|
|
448
|
+
...(reachRow ? [reachRow] : []),
|
|
392
449
|
[
|
|
393
450
|
{ text: "📝 Make a rule…", callback_data: `apr:${rec.id}:rule` },
|
|
394
451
|
...(verifyRow || []),
|
|
@@ -426,7 +483,12 @@ async function escalate({
|
|
|
426
483
|
const approvals = require("./approvals");
|
|
427
484
|
const relationship = require("./relationship");
|
|
428
485
|
const audit = safeAudit();
|
|
429
|
-
|
|
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];
|
|
430
492
|
if (!owner) {
|
|
431
493
|
if (audit) audit.log("enforcer.escalate.failed", { reason: "no reachable owner", guardKind: kind, person: speaker && speaker.name });
|
|
432
494
|
return { ok: false, escalated: false, reason: "no reachable owner to approve" };
|
|
@@ -478,6 +540,22 @@ async function escalate({
|
|
|
478
540
|
}
|
|
479
541
|
}
|
|
480
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 = ""; }
|
|
481
559
|
const rec = approvals.create({
|
|
482
560
|
tool: kind === "reply" ? "external-reply" : kind === "triage" ? "external-triage" : "external-action",
|
|
483
561
|
verb: "",
|
|
@@ -507,8 +585,12 @@ async function escalate({
|
|
|
507
585
|
entitySlug: (speaker && speaker.slug) || "",
|
|
508
586
|
inboundText,
|
|
509
587
|
channelLabel,
|
|
588
|
+
speakerId: (speaker && speaker.speakerId) || "",
|
|
589
|
+
room: (speaker && speaker.room) || "",
|
|
510
590
|
});
|
|
511
|
-
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);
|
|
512
594
|
if (!posted) {
|
|
513
595
|
approvals.decide(rec.id, "denied", "owner-unreachable");
|
|
514
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;
|
|
@@ -103,25 +103,26 @@ async function notifyOwnerAboutIntro(intro) {
|
|
|
103
103
|
// from people.isOwner: dropping it here is what demoted owners approved onto
|
|
104
104
|
// a second channel (isChatOwner reads this file, not the person).
|
|
105
105
|
function ensureAuthEntry(channelId, { name = null, username = null, isOwner = false } = {}) {
|
|
106
|
-
const auth = loadAuth();
|
|
107
106
|
const id = String(channelId);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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;
|
|
113
116
|
}
|
|
117
|
+
auth.authorized.push({
|
|
118
|
+
chatId: id,
|
|
119
|
+
name: name || "",
|
|
120
|
+
username: username || null,
|
|
121
|
+
isOwner: !!isOwner,
|
|
122
|
+
authorizedAt: new Date().toISOString(),
|
|
123
|
+
});
|
|
114
124
|
return auth;
|
|
115
|
-
}
|
|
116
|
-
auth.authorized.push({
|
|
117
|
-
chatId: id,
|
|
118
|
-
name: name || "",
|
|
119
|
-
username: username || null,
|
|
120
|
-
isOwner: !!isOwner,
|
|
121
|
-
authorizedAt: new Date().toISOString(),
|
|
122
125
|
});
|
|
123
|
-
saveAuth(auth);
|
|
124
|
-
return auth;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
// A sender whose transport user id already maps to a person is never
|
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
|
};
|