@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/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
- const owner = await relationship.ownerTarget();
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 posted = await postOwnerButtons(rec, owner, { kind, summary, reason });
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
- module.exports = { atomicWriteFileSync, readJsonWithFallback };
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 }) => {
@@ -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, loadAuth, saveAuth, updateAuthorizedChatEnv } = require("./access");
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 false;
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 false;
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 work.
231
- const auth = loadAuth();
232
- if (!auth.authorized.some((a) => String(a.chatId) === String(intro.channelId))) {
233
- auth.authorized.push({
234
- chatId: String(intro.channelId),
235
- name: intro.displayName || person.name,
236
- username: intro.username || null,
237
- isOwner: false,
238
- authorizedAt: new Date().toISOString(),
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
  };