@inetafrica/open-claudia 3.4.24 → 3.5.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.
@@ -10,6 +10,8 @@
10
10
 
11
11
  const path = require("path");
12
12
  const index = require(path.join(__dirname, "..", "core", "transcript-index"));
13
+ const { whoami } = require("./loopback-client");
14
+ const { transcriptOwnerOf } = require(path.join(__dirname, "..", "project-transcripts"));
13
15
 
14
16
  function parseArgs(argv) {
15
17
  const out = { query: [], all: false, project: null, limit: 8, raw: false, rebuild: false, json: false, help: false };
@@ -47,7 +49,41 @@ Options:
47
49
  Exit codes: 0 hits, 1 no hits, 2 usage error, 3 index unavailable.`);
48
50
  }
49
51
 
50
- function run(argv) {
52
+ // A non-owner's search must stay in their own room: no cross-room widening
53
+ // (--all / --project / --rebuild) and results scoped to the caller's OWN
54
+ // transcript. The room is decided by the RUNNING BOT (whoami — run-attested),
55
+ // never by the process's own env; and the scoped file is confirmed to belong
56
+ // to that principal, so an env-forged OC_TRANSCRIPT_PATH aimed at someone
57
+ // else's transcript is refused. An attributed owner is unrestricted.
58
+ async function resolveScope(opts) {
59
+ let caller;
60
+ try { caller = await whoami(); } catch (e) { caller = { attributed: false, owner: false }; }
61
+ if (caller && caller.owner === true) {
62
+ const file = (!opts.all && !opts.project) ? (process.env.OC_TRANSCRIPT_PATH || null) : null;
63
+ if (!opts.all && !opts.project && !file) {
64
+ console.error("No current-project transcript (OC_TRANSCRIPT_PATH unset). Use --all, --project <name>, or run inside a bot-spawned task.");
65
+ process.exit(2);
66
+ }
67
+ return { file, project: opts.project, all: opts.all };
68
+ }
69
+ if (opts.all || opts.project || opts.rebuild) {
70
+ console.error("Searching or reindexing other rooms (--all / --project / --rebuild) is owner-only. Your search is limited to this room.");
71
+ process.exit(2);
72
+ }
73
+ const file = process.env.OC_TRANSCRIPT_PATH || null;
74
+ if (!file) {
75
+ console.error("No room transcript to search (OC_TRANSCRIPT_PATH unset).");
76
+ process.exit(2);
77
+ }
78
+ const owner = transcriptOwnerOf(file);
79
+ if (!caller || !caller.attributed || !caller.canonicalUserId || owner !== caller.canonicalUserId) {
80
+ console.error("That transcript is not yours to search — search is limited to your own room.");
81
+ process.exit(2);
82
+ }
83
+ return { file, project: null, all: false };
84
+ }
85
+
86
+ async function run(argv) {
51
87
  const opts = parseArgs(argv);
52
88
  if (opts.help) { printHelp(); process.exit(0); }
53
89
 
@@ -56,6 +92,10 @@ function run(argv) {
56
92
  process.exit(3);
57
93
  }
58
94
 
95
+ // Resolve (and room-restrict) the scope BEFORE any index write: a non-owner
96
+ // may not --rebuild the whole index either.
97
+ const scope = await resolveScope(opts);
98
+
59
99
  if (opts.rebuild) {
60
100
  const r = index.rebuild();
61
101
  console.error(`Rebuilt index: ${r.added} entries from ${r.files} transcript files.`);
@@ -67,13 +107,7 @@ function run(argv) {
67
107
 
68
108
  if (!opts.query) { printHelp(); process.exit(2); }
69
109
 
70
- const file = (!opts.all && !opts.project) ? (process.env.OC_TRANSCRIPT_PATH || null) : null;
71
- if (!opts.all && !opts.project && !file) {
72
- console.error("No current-project transcript (OC_TRANSCRIPT_PATH unset). Use --all, --project <name>, or run inside a bot-spawned task.");
73
- process.exit(2);
74
- }
75
-
76
- const { hits, error } = index.search(opts.query, { project: opts.project, file, limit: opts.limit, raw: opts.raw });
110
+ const { hits, error } = index.search(opts.query, { project: scope.project, file: scope.file, limit: opts.limit, raw: opts.raw });
77
111
  if (error) { console.error(`Query error: ${error}${opts.raw ? "" : " (internal — please report)"}`); process.exit(2); }
78
112
  if (hits.length === 0) {
79
113
  console.error(`No matches for ${JSON.stringify(opts.query)}${opts.all ? " across all projects" : ""}.`);
@@ -10,6 +10,9 @@
10
10
  // 3. error out — we don't try to guess
11
11
 
12
12
  const fs = require("fs");
13
+ const path = require("path");
14
+ const { whoami } = require("./loopback-client");
15
+ const { transcriptOwnerOf } = require(path.join(__dirname, "..", "project-transcripts"));
13
16
 
14
17
  const DEFAULTS = {
15
18
  before: 2,
@@ -78,6 +81,32 @@ function resolveTranscriptPath(flagPath) {
78
81
  return null;
79
82
  }
80
83
 
84
+ // A non-owner may only read their OWN room's transcript: --path (an arbitrary
85
+ // file, e.g. the owner's) is refused, and the default OC_TRANSCRIPT_PATH is
86
+ // confirmed to belong to the caller (whoami — run-attested, not the process's
87
+ // own env) so an env-forged path aimed elsewhere is refused. Returns the
88
+ // transcript path to read, or exits with a refusal. Owner is unrestricted.
89
+ async function resolveScopedTranscriptPath(opts) {
90
+ let caller;
91
+ try { caller = await whoami(); } catch (e) { caller = { attributed: false, owner: false }; }
92
+ if (caller && caller.owner === true) return resolveTranscriptPath(opts.path);
93
+ if (opts.path) {
94
+ console.error("Reading another transcript with --path is owner-only. You can read only your own room's transcript.");
95
+ process.exit(2);
96
+ }
97
+ const file = process.env.OC_TRANSCRIPT_PATH || null;
98
+ if (!file) {
99
+ console.error("No room transcript to read (OC_TRANSCRIPT_PATH unset).");
100
+ process.exit(2);
101
+ }
102
+ const owner = transcriptOwnerOf(file);
103
+ if (!caller || !caller.attributed || !caller.canonicalUserId || owner !== caller.canonicalUserId) {
104
+ console.error("That transcript is not yours to read — you can read only your own room's transcript.");
105
+ process.exit(2);
106
+ }
107
+ return file;
108
+ }
109
+
81
110
  function loadTurns(transcriptPath) {
82
111
  const raw = fs.readFileSync(transcriptPath, "utf-8");
83
112
  const turns = [];
@@ -125,13 +154,13 @@ function formatTurn(turn, label, maxChars) {
125
154
  ].join("\n");
126
155
  }
127
156
 
128
- function run(argv) {
157
+ async function run(argv) {
129
158
  const opts = parseArgs(argv);
130
159
  if (opts.help || !opts.pattern) {
131
160
  printHelp();
132
161
  process.exit(opts.help ? 0 : 2);
133
162
  }
134
- const transcriptPath = resolveTranscriptPath(opts.path);
163
+ const transcriptPath = await resolveScopedTranscriptPath(opts);
135
164
  if (!transcriptPath) {
136
165
  console.error("No transcript path. Pass --path <file>, or run inside an active bot-spawned task (OC_TRANSCRIPT_PATH).");
137
166
  process.exit(2);
package/core/actions.js CHANGED
@@ -741,15 +741,26 @@ async function handleAction(envelope) {
741
741
  const reach = parts[2]; // "room" | "all"
742
742
  if (reach !== "room" && reach !== "all") return;
743
743
  if (!rec) return send("That request expired — nothing to grant from it.");
744
+ // Destructive is never a standing permission: each destructive run gets
745
+ // its own card, however often the owner has approved one before.
746
+ if (String(rec.tier || "").toLowerCase() === "destructive") {
747
+ return send("Destructive actions always ask; no standing permission. Use Approve once for this run.");
748
+ }
744
749
  if (!rec.speakerId || !rec.room) return send("I can't tell who asked apart from where they asked — no grant written.");
750
+ // A grant covers one identifiable person. An anonymous admin, a channel
751
+ // post, a missing sender or the bot itself would hand it to whoever
752
+ // hides behind that id.
753
+ const grantsLib = require("./grants");
754
+ const refusal = grantsLib.principalRefusal({ adapter: rec.targetAdapter || rec.adapter, senderId: rec.speakerId, room: rec.room });
755
+ if (refusal) {
756
+ return send(`No standing permission for ${refusal} — a grant needs one identifiable person. Use Approve once for this request.`);
757
+ }
745
758
  // A reply/triage card only ever cleared talking; an action card carries the
746
759
  // tier it asked for. Never grant more than the card actually showed.
747
- const capability = rec.kind === "action"
748
- ? (String(rec.tier || "") === "destructive" ? "destructive" : "act")
749
- : "read";
760
+ const capability = rec.kind === "action" ? "act" : "read";
750
761
  let record;
751
762
  try {
752
- record = require("./grants").grant({
763
+ record = grantsLib.grant({
753
764
  adapter: rec.targetAdapter || rec.adapter,
754
765
  senderId: rec.speakerId,
755
766
  room: rec.room,
package/core/approvals.js CHANGED
@@ -112,7 +112,7 @@ function prune() {
112
112
  // speaker), the vetted `payloadText` to deliver on a reply approval, the
113
113
  // `target*` external channel, and person/entity pointers for Always-allow.
114
114
  function create({
115
- tool, verb = "", tier = "destructive", command = "", channelId = "", adapter = "",
115
+ tool, verb = "", tier = "", command = "", channelId = "", adapter = "",
116
116
  canonicalUserId = "", userId = "", provider = "", providerSettings = {},
117
117
  project = "", projectDir = "", sessionId = "", previousSessionId = "", originRunId = "",
118
118
  kind = "tool", approverAdapter = "", approverChannel = "",
@@ -205,10 +205,30 @@ function detach(id) {
205
205
  } finally { releaseRecordLock(held); }
206
206
  }
207
207
 
208
+ // Risk tiers an approval can carry for a tool run. A record's `tier` is what
209
+ // the owner saw on the card; reply/triage records carry non-tool tiers.
210
+ const TIER_RANK = { "read-only": 0, write: 1, destructive: 2 };
211
+
212
+ function riskTier(value) {
213
+ const tier = String(value || "").trim().toLowerCase();
214
+ return Object.prototype.hasOwnProperty.call(TIER_RANK, tier) ? tier : "";
215
+ }
216
+
217
+ // The tier a redeemed approval authorizes: exactly the tier on the record the
218
+ // owner approved. A record without one (created before tiers were recorded)
219
+ // falls back to the tier the tool/verb declares for this run — never to
220
+ // "destructive" by default.
221
+ function approvedTier(record, runTier) {
222
+ return riskTier(record && record.tier) || riskTier(runTier) || "read-only";
223
+ }
224
+
208
225
  // One-shot token redemption for the async path. Fail-closed on every branch:
209
226
  // only an approved, un-consumed, un-expired record whose stored command is
210
227
  // byte-identical to what is about to run can be consumed — and only once.
211
- function consume(id, commandLine) {
228
+ // With `tier` (the run's resolved tier), a run that now resolves above the tier
229
+ // the owner approved (the verb was reclassified since) is refused WITHOUT
230
+ // consuming the token: an approval never stretches past what the card showed.
231
+ function consume(id, commandLine, { tier = "" } = {}) {
212
232
  try { fileOf(id); }
213
233
  catch (_) { return { ok: false, reason: "unknown or expired approval id" }; }
214
234
  const held = acquireRecordLock(id);
@@ -231,6 +251,11 @@ function consume(id, commandLine) {
231
251
  : Buffer.byteLength(String(rec.command || "")) <= MAX_COMMAND_BYTES
232
252
  && candidate === String(rec.command || "");
233
253
  if (!matches) return { ok: false, reason: "command does not match the approved payload" };
254
+ const approved = riskTier(rec.tier);
255
+ const wanted = riskTier(tier);
256
+ if (approved && wanted && TIER_RANK[wanted] > TIER_RANK[approved]) {
257
+ return { ok: false, reason: `approval covers a ${approved} run, but this run is ${wanted}` };
258
+ }
234
259
  rec.consumedAt = new Date().toISOString();
235
260
  writeRecord(rec);
236
261
  return { ok: true, record: rec };
@@ -277,6 +302,7 @@ function markMined(ids) {
277
302
  module.exports = {
278
303
  APPROVALS_DIR,
279
304
  MAX_COMMAND_BYTES,
305
+ approvedTier,
280
306
  commandHash,
281
307
  consume,
282
308
  create,
package/core/grants.js CHANGED
@@ -25,6 +25,13 @@ const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
25
25
  // maps a proposed action onto the same ladder and refuses anything above.
26
26
  const CAPABILITIES = ["read", "act", "destructive"];
27
27
 
28
+ // Destructive is never a standing permission: every destructive run gets its
29
+ // own owner card. grant() refuses it, and a legacy `destructive` record still
30
+ // on disk counts as `act` — the owner plainly trusted that person to act, but
31
+ // a destructive run keeps asking. The file itself is left untouched.
32
+ const STANDING_CEILING = "act";
33
+ const DESTRUCTIVE_REFUSAL = "Destructive actions always ask; no standing permission.";
34
+
28
35
  const ALL_ROOMS = "*";
29
36
 
30
37
  function nowIso() { return new Date().toISOString(); }
@@ -39,6 +46,78 @@ function normalizeCapability(capability) {
39
46
  return CAPABILITIES.includes(c) ? c : "read";
40
47
  }
41
48
 
49
+ function grantError(code, message) {
50
+ const error = new Error(message);
51
+ error.code = code;
52
+ return error;
53
+ }
54
+
55
+ // Logged once per record per process, so a busy room doesn't flood the audit.
56
+ const noted = new Set();
57
+ function noteOnce(kind, g, extra = {}) {
58
+ const key = `${kind}:${g && g.id}`;
59
+ if (noted.has(key)) return;
60
+ noted.add(key);
61
+ try {
62
+ require("./audit").log(kind, {
63
+ id: g && g.id,
64
+ principal: principalKey(g && g.principal && g.principal.adapter, g && g.principal && g.principal.channelId),
65
+ room: g && g.room,
66
+ ...extra,
67
+ });
68
+ } catch (e) { /* best effort */ }
69
+ }
70
+
71
+ // The capability a stored grant actually confers.
72
+ function effectiveCapability(g) {
73
+ const stored = normalizeCapability(g && g.capability);
74
+ if (capabilityRank(stored) <= capabilityRank(STANDING_CEILING)) return stored;
75
+ noteOnce("grant.destructive-downgraded", g, { effective: STANDING_CEILING });
76
+ return STANDING_CEILING;
77
+ }
78
+
79
+ // Senders that are not one identifiable person. A grant to any of them would
80
+ // cover whoever hides behind them, so they are refused. Telegram gives
81
+ // messages sent on behalf of a chat (Bot API `sender_chat`) a fake `from`,
82
+ // and each of these ids is shared by EVERY such sender in every group.
83
+ const TELEGRAM_STAND_IN_SENDERS = new Map([
84
+ ["1087968824", "an anonymous group admin"], // @GroupAnonymousBot
85
+ ["136817688", "someone posting as a channel"], // @Channel_Bot
86
+ ["777000", "a channel post forwarded by Telegram"], // Telegram service account
87
+ ]);
88
+
89
+ // The bot's own ids, from the operator's config (never the token itself).
90
+ function botSelfIds(adapter) {
91
+ let cfg = {};
92
+ try { cfg = require("./config").config || {}; } catch (e) { return []; }
93
+ const ids = [];
94
+ if (adapter === "telegram") ids.push(String(cfg.TELEGRAM_BOT_TOKEN || "").split(":")[0].trim());
95
+ if (adapter === "kazee" || adapter === "spaces") ids.push(String(cfg.KAZEE_BOT_USER_ID || "").trim());
96
+ return ids.filter(Boolean);
97
+ }
98
+
99
+ // null when (adapter, sender) is one verifiable person; otherwise who it is
100
+ // instead, for the refusal message. `room` is the `<surface>:<id>` key.
101
+ function principalRefusal({ adapter, senderId, room = "" } = {}) {
102
+ const a = String(adapter || "").trim().toLowerCase();
103
+ const s = String(senderId || "").trim();
104
+ if (!a || !s) return "a sender with no id";
105
+ if (botSelfIds(a).includes(s)) return "the bot itself";
106
+ if (a === "telegram") {
107
+ if (TELEGRAM_STAND_IN_SENDERS.has(s)) return TELEGRAM_STAND_IN_SENDERS.get(s);
108
+ // Telegram people have positive numeric ids; groups and channels negative.
109
+ if (!/^\d+$/.test(s)) return "a group or channel rather than a person";
110
+ }
111
+ if (a === "spaces" && s.startsWith("unknown-actor:")) return "an unidentified Spaces actor";
112
+ // An adapter that lost the sender falls back to the room id (Kazee does);
113
+ // only a Telegram DM legitimately has sender id == chat id.
114
+ const r = String(room || "");
115
+ if (a !== "telegram" && r.startsWith(`${a}:`) && r.slice(a.length + 1) === s) {
116
+ return "the room itself (no sender id)";
117
+ }
118
+ return null;
119
+ }
120
+
42
121
  // Rooms are keyed `<surface>:<id>`, matching core/identity.conversationKeyFor
43
122
  // so a caller can hand us a conversation key directly.
44
123
  function roomKey(surface, roomId) {
@@ -72,48 +151,87 @@ function isLive(grant, at = Date.now()) {
72
151
  return true;
73
152
  }
74
153
 
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 }) {
154
+ // Every live grant for this principal that reaches this room. Records for a
155
+ // principal that could not be granted today (anonymous, the bot itself) are
156
+ // ignored rather than honoured.
157
+ function matchingGrants({ adapter, senderId, room }) {
80
158
  const principal = principalKey(adapter, senderId);
159
+ if (!principal) return [];
81
160
  const target = String(room || "");
82
- if (!principal) return null;
83
161
  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);
162
+ return load().filter((g) => {
163
+ if (!isLive(g, at)) return false;
164
+ if (principalKey(g.principal?.adapter, g.principal?.channelId) !== principal) return false;
165
+ if (g.room !== target && g.room !== ALL_ROOMS) return false;
166
+ const refusal = principalRefusal({ adapter: g.principal?.adapter, senderId: g.principal?.channelId, room: g.room });
167
+ if (refusal) { noteOnce("grant.unverifiable-ignored", g, { refusal }); return false; }
168
+ return true;
94
169
  });
95
- return candidates[0];
96
170
  }
97
171
 
98
- // True when this principal may take an action at `needed` strength in `room`.
172
+ // The record as it applies now: a legacy destructive grant reads as `act`.
173
+ function asEffective(g) {
174
+ const effective = effectiveCapability(g);
175
+ return effective === normalizeCapability(g.capability) ? g : { ...g, capability: effective, storedCapability: g.capability };
176
+ }
177
+
178
+ // Among grants that all answer the question: a standing grant before a
179
+ // turn-limited one (never burn a "just this once" while a standing grant
180
+ // covers it), then the exact room before all-rooms (the audit names the most
181
+ // specific record), then the newest.
182
+ function preference(target) {
183
+ return (a, b) => ((a.turnsRemaining != null) - (b.turnsRemaining != null))
184
+ || ((b.room === target) - (a.room === target))
185
+ || String(b.grantedAt || "").localeCompare(String(a.grantedAt || ""));
186
+ }
187
+
188
+ function strongestFirst(target) {
189
+ const prefer = preference(target);
190
+ return (a, b) => (capabilityRank(effectiveCapability(b)) - capabilityRank(effectiveCapability(a))) || prefer(a, b);
191
+ }
192
+
193
+ // The deterministic lookup that replaces the LLM as the gate. Grants add up:
194
+ // the effective grant is the STRONGEST live match, so an exact-room grant
195
+ // never weakens an all-rooms one or the other way round. To take access
196
+ // away, revoke; a narrower grant does not.
197
+ function resolve({ adapter, senderId, room }) {
198
+ const candidates = matchingGrants({ adapter, senderId, room });
199
+ if (!candidates.length) return null;
200
+ candidates.sort(strongestFirst(String(room || "")));
201
+ return asEffective(candidates[0]);
202
+ }
203
+
204
+ // True when this principal may take an action at `needed` strength in `room`:
205
+ // some matching grant is strong enough and its scope covers the subject.
206
+ // Destructive never passes — no standing grant confers it.
99
207
  // Owners never reach here — they are resolved before any grant lookup.
100
208
  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 };
209
+ const target = String(room || "");
210
+ const candidates = matchingGrants({ adapter, senderId, room });
211
+ if (!candidates.length) return { ok: false, reason: "no_grant", grant: null };
212
+ const strong = candidates.filter((g) => capabilityRank(effectiveCapability(g)) >= capabilityRank(needed));
213
+ if (!strong.length) {
214
+ candidates.sort(strongestFirst(target));
215
+ return { ok: false, reason: "capability_too_low", grant: asEffective(candidates[0]) };
105
216
  }
106
- const scope = Array.isArray(grant.scope) ? grant.scope : [];
107
217
  // An empty scope means "anything within this capability" — a scoped grant
108
218
  // only covers the subjects it names.
109
- if (scope.length && subject && !scope.includes(String(subject))) {
110
- return { ok: false, reason: "out_of_scope", grant };
219
+ const covers = (g) => {
220
+ const scope = Array.isArray(g.scope) ? g.scope : [];
221
+ return !scope.length || !subject || scope.includes(String(subject));
222
+ };
223
+ const covering = strong.filter(covers);
224
+ if (!covering.length) {
225
+ strong.sort(preference(target));
226
+ return { ok: false, reason: "out_of_scope", grant: asEffective(strong[0]) };
111
227
  }
112
- return { ok: true, reason: "granted", grant };
228
+ covering.sort(preference(target));
229
+ return { ok: true, reason: "granted", grant: asEffective(covering[0]) };
113
230
  }
114
231
 
115
232
  // Only an owner may call this — enforced by the caller, which holds the turn
116
- // context needed to know who is asking.
233
+ // context needed to know who is asking. Refuses destructive (never standing)
234
+ // and any principal that is not one identifiable person.
117
235
  function grant({
118
236
  adapter,
119
237
  senderId,
@@ -130,6 +248,13 @@ function grant({
130
248
  if (!principal) throw new Error("adapter and senderId are required");
131
249
  const target = allRooms ? ALL_ROOMS : String(room || "");
132
250
  if (!target) throw new Error("room is required unless allRooms is set");
251
+ if (capabilityRank(normalizeCapability(capability)) > capabilityRank(STANDING_CEILING)) {
252
+ throw grantError("GRANT_DESTRUCTIVE_REFUSED", DESTRUCTIVE_REFUSAL);
253
+ }
254
+ const refusal = principalRefusal({ adapter, senderId, room });
255
+ if (refusal) {
256
+ throw grantError("GRANT_PRINCIPAL_UNVERIFIABLE", `No standing permission for ${refusal} — a grant needs one identifiable person.`);
257
+ }
133
258
  const grants = load();
134
259
  // Re-granting the same pair replaces rather than stacks, so revoking one
135
260
  // record can never leave a weaker duplicate behind still granting access.
@@ -203,12 +328,16 @@ function listForRoom(room, { includeExpired = false } = {}) {
203
328
 
204
329
  module.exports = {
205
330
  CAPABILITIES,
331
+ STANDING_CEILING,
332
+ DESTRUCTIVE_REFUSAL,
206
333
  ALL_ROOMS,
207
334
  GRANTS_FILE,
208
335
  roomKey,
209
336
  principalKey,
337
+ principalRefusal,
210
338
  capabilityRank,
211
339
  normalizeCapability,
340
+ effectiveCapability,
212
341
  isLive,
213
342
  resolve,
214
343
  allows,
package/core/handlers.js CHANGED
@@ -1928,13 +1928,19 @@ register({
1928
1928
 
1929
1929
  const live = grants.list();
1930
1930
  if (live.length === 0) {
1931
- 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\").");
1931
+ 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\"). Destructive actions always ask; they can't be granted.");
1932
1932
  }
1933
1933
  const lines = live.map((g) => {
1934
1934
  const where = g.room === grants.ALL_ROOMS ? "everywhere" : g.room;
1935
1935
  const until = g.expiresAt ? ` · until ${g.expiresAt.slice(0, 10)}` : "";
1936
1936
  const turns = g.turnsRemaining != null ? ` · ${g.turnsRemaining} turn(s) left` : "";
1937
- return `🔑 [${g.id}] ${g.principal.adapter}:${g.principal.channelId} → ${g.capability} in ${where}${until}${turns}`;
1937
+ // Legacy records: a destructive grant confers act only (destructive
1938
+ // always asks); one for an anonymous or unverifiable sender is ignored.
1939
+ const capability = grants.effectiveCapability(g);
1940
+ const legacy = grants.normalizeCapability(g.capability) !== capability ? ` (stored as ${g.capability}; destructive always asks)` : "";
1941
+ const refusal = grants.principalRefusal({ adapter: g.principal.adapter, senderId: g.principal.channelId, room: g.room });
1942
+ const ignored = refusal ? ` — ignored: ${refusal}` : "";
1943
+ return `🔑 [${g.id}] ${g.principal.adapter}:${g.principal.channelId} → ${capability}${legacy} in ${where}${until}${turns}${ignored}`;
1938
1944
  });
1939
1945
  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}`);
1940
1946
  },