@inetafrica/open-claudia 3.4.23 → 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.
- package/CHANGELOG.md +50 -0
- package/bin/agent.js +4 -0
- package/bin/cli.js +10 -1
- package/bin/entity.js +17 -15
- package/bin/ideas.js +10 -1
- package/bin/keyring.js +19 -18
- package/bin/lessons.js +10 -1
- package/bin/loopback-client.js +46 -1
- package/bin/owner-gate.js +38 -0
- package/bin/pack.js +11 -1
- package/bin/schedule.js +45 -11
- package/bin/tool.js +77 -49
- package/bin/transcript-search.js +42 -8
- package/bin/transcript-window.js +31 -2
- package/core/actions.js +15 -4
- package/core/approvals.js +28 -2
- package/core/grants.js +156 -27
- package/core/handlers.js +8 -2
- package/core/loopback.js +237 -71
- package/core/providers/claude-hook.js +5 -0
- package/core/runner.js +47 -2
- package/core/scheduler.js +307 -37
- package/core/speaker-attest.js +82 -1
- package/core/tool-guard.js +380 -11
- package/package.json +2 -2
- package/project-transcripts.js +25 -0
- package/test-approval-async.js +22 -0
- package/test-approval-cards.js +40 -0
- package/test-provider-compaction.js +93 -0
- package/test-provider-language.js +15 -1
- package/test-provider-pack-review.js +25 -2
- package/test-provider-parity-e2e.js +5 -3
- package/test-provider-scheduler.js +504 -3
- package/test-provider-tool-hooks.js +11 -0
- package/test-run-lock.js +2 -0
- package/test-tool-guard.js +12 -3
- package/test-tool-manifest.js +31 -0
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
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
|
-
|
|
85
|
-
isLive(g, at)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
//
|
|
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
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|