@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/loopback.js
CHANGED
|
@@ -58,6 +58,14 @@ function reply(res, code, body) {
|
|
|
58
58
|
res.end(data);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// An adapter's send* resolves false after logging its own error, so the JSON
|
|
62
|
+
// used to say only {"ok":false} (10 Sep: every `say` failed like that through
|
|
63
|
+
// a Kazee outage). Name the step that failed; the adapter's message may carry
|
|
64
|
+
// request details, so it stays in bot.log.
|
|
65
|
+
function undeliveredReason(adapter, what) {
|
|
66
|
+
return `${adapter.type || adapter.id} adapter did not deliver the ${what}: the channel rejected it or was unreachable (details in bot.log)`;
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
function authOk(req) {
|
|
62
70
|
return (req.headers["authorization"] || "") === `Bearer ${token}`;
|
|
63
71
|
}
|
|
@@ -73,7 +81,19 @@ function readBodyToFile(req, dest) {
|
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
|
|
84
|
+
// Loopback kinds that CREATE a background job. Their creator identity is bound
|
|
85
|
+
// to callerOf(payload) (run-attested), not to the payload's forgeable claims.
|
|
86
|
+
const JOB_CREATE_KINDS = new Set(["schedule-wakeup", "cron-add", "watch-add"]);
|
|
87
|
+
// Owner-only kinds gated centrally on the run-attested caller: notes and tasks
|
|
88
|
+
// (private owner context) plus the people reads (the roster and records expose
|
|
89
|
+
// everyone the owner knows). people mutations keep their own inline gate too.
|
|
90
|
+
const OWNER_ONLY_KINDS = new Set([
|
|
91
|
+
"people-list", "people-show", "people-note",
|
|
92
|
+
"task-add", "task-plan", "task-update", "task-remove",
|
|
93
|
+
"task-list", "task-get", "task-tree", "task-clear-completed",
|
|
94
|
+
]);
|
|
76
95
|
const JSON_KINDS = new Set([
|
|
96
|
+
"whoami",
|
|
77
97
|
"schedule-wakeup", "cron-add", "cron-remove", "job-list",
|
|
78
98
|
"watch-add", "watch-list", "watch-remove", "watch-toggle",
|
|
79
99
|
"task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
|
|
@@ -83,22 +103,46 @@ const JSON_KINDS = new Set([
|
|
|
83
103
|
"relay-send", "recent-fetch", "audit-tail",
|
|
84
104
|
"auth-list", "auth-revoke",
|
|
85
105
|
"approval-request",
|
|
106
|
+
"tool-gate",
|
|
86
107
|
"say", "call-say", "call", "tts", "transcribe",
|
|
87
108
|
]);
|
|
88
109
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
// Who is calling, decided by the bot: the principal of a LIVE run whose
|
|
111
|
+
// OC_RUN_ATTEST matches (see core/speaker-attest), else UNATTRIBUTED. Nothing
|
|
112
|
+
// the caller's env claims (OC_CHANNEL_*, OC_CANONICAL_USER_ID) is consulted,
|
|
113
|
+
// and unattributed never carries owner powers.
|
|
114
|
+
function callerOf(payload) {
|
|
115
|
+
const principal = speakerAttest.verifyRun(payload?.originRunId || payload?.runId, payload?.runAttest);
|
|
116
|
+
if (principal) {
|
|
117
|
+
const { registeredAt, ...caller } = principal;
|
|
118
|
+
return { attributed: true, ...caller };
|
|
119
|
+
}
|
|
120
|
+
return { attributed: false, owner: false, guarded: true, kind: "unattributed" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A durable, run-attested record of who created a job, stamped alongside its
|
|
124
|
+
// identity fields. Distinguishes a job created since this fix (has createdBy)
|
|
125
|
+
// from a legacy job on disk (no createdBy) — the scheduler runs the latter as
|
|
126
|
+
// before, but logs it. Never trusted for authorization; the fire path
|
|
127
|
+
// re-resolves the speaker from the job's channel/user via runPrincipal.
|
|
128
|
+
function jobCreatorRecord(creator) {
|
|
129
|
+
return {
|
|
130
|
+
canonicalUserId: creator.canonicalUserId || null,
|
|
131
|
+
userId: creator.userId || null,
|
|
132
|
+
channelId: creator.channelId || null,
|
|
133
|
+
adapterType: creator.adapterType || null,
|
|
134
|
+
owner: creator.owner === true,
|
|
135
|
+
name: creator.name || null,
|
|
136
|
+
at: new Date().toISOString(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Owner gate for every owner-only kind: true only for a LIVE run the bot
|
|
141
|
+
// registered as acting for the owner. The id attest (OC_SPEAKER_ATTEST) is no
|
|
142
|
+
// longer enough: it is valid for the whole boot and can be lifted from another
|
|
143
|
+
// process's environment. Identity fields in the payload are never consulted.
|
|
96
144
|
function callerIsOwner(payload) {
|
|
97
|
-
|
|
98
|
-
if (!caller) return false;
|
|
99
|
-
if (!speakerAttest.verify(caller, payload?.speakerAttest)) return false;
|
|
100
|
-
const person = peopleStore.findByCanonicalUserId(caller);
|
|
101
|
-
return !!(person && person.isOwner);
|
|
145
|
+
return callerOf(payload).owner === true;
|
|
102
146
|
}
|
|
103
147
|
|
|
104
148
|
function readBodyAsString(req, max = 64 * 1024) {
|
|
@@ -154,7 +198,7 @@ async function handleSend(req, res, url, kind) {
|
|
|
154
198
|
} finally {
|
|
155
199
|
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
156
200
|
}
|
|
157
|
-
return reply(res, ok
|
|
201
|
+
return ok ? reply(res, 200, { ok }) : reply(res, 500, { ok, error: undeliveredReason(adapter, kind === "send-voice" ? "voice note" : kind.slice(5)) });
|
|
158
202
|
}
|
|
159
203
|
|
|
160
204
|
async function handleJson(req, res, url, kind) {
|
|
@@ -164,6 +208,11 @@ async function handleJson(req, res, url, kind) {
|
|
|
164
208
|
try { payload = JSON.parse(body); }
|
|
165
209
|
catch (e) { return reply(res, 400, { error: "invalid JSON body" }); }
|
|
166
210
|
}
|
|
211
|
+
// "Who am I?" needs no chat context: an unattributed caller (a process that
|
|
212
|
+
// outlived its turn, a stripped env) must still get a definite answer.
|
|
213
|
+
if (kind === "whoami") return reply(res, 200, { ok: true, caller: callerOf(payload) });
|
|
214
|
+
// Decided from the verified run alone, so it needs no chat fields either.
|
|
215
|
+
if (kind === "tool-gate") return handleToolGate(res, payload);
|
|
167
216
|
const channelId = String(payload.channelId || url.searchParams.get("channelId") || "");
|
|
168
217
|
const userId = String(payload.userId || url.searchParams.get("userId") || "");
|
|
169
218
|
const adapterId = String(payload.adapter || url.searchParams.get("adapter") || "");
|
|
@@ -171,6 +220,40 @@ async function handleJson(req, res, url, kind) {
|
|
|
171
220
|
const adapter = registry.findAdapter(adapterId);
|
|
172
221
|
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
173
222
|
|
|
223
|
+
// A background job belongs to WHOEVER the bot says is running the turn that
|
|
224
|
+
// created it (callerOf, run-bound) and to THAT person's room — never to the
|
|
225
|
+
// identity the payload claims (OC_CHANNEL_*/OC_CANONICAL_USER_ID, which the
|
|
226
|
+
// run's own shell can rewrite) and never to the bot's ambient owner state.
|
|
227
|
+
// So a follow-up a staff turn schedules fires AS that staff member, in their
|
|
228
|
+
// room, and all of their guards apply when it runs. An unattributed caller
|
|
229
|
+
// (a process detached from any live turn) cannot schedule anything.
|
|
230
|
+
let jobCreator = null;
|
|
231
|
+
if (JOB_CREATE_KINDS.has(kind)) {
|
|
232
|
+
const caller = callerOf(payload);
|
|
233
|
+
if (!caller.attributed) {
|
|
234
|
+
return reply(res, 403, { error: "background jobs can only be scheduled by an attributed run (no live turn is speaking for this process)" });
|
|
235
|
+
}
|
|
236
|
+
const creatorAdapter = caller.adapterId ? registry.findAdapter(caller.adapterId) : null;
|
|
237
|
+
jobCreator = {
|
|
238
|
+
canonicalUserId: caller.canonicalUserId || null,
|
|
239
|
+
userId: caller.userId || null,
|
|
240
|
+
channelId: caller.channelId || channelId,
|
|
241
|
+
adapter: (creatorAdapter && creatorAdapter.id) || caller.adapterId || adapterId,
|
|
242
|
+
adapterType: (creatorAdapter && creatorAdapter.type) || caller.adapterType || adapter.type,
|
|
243
|
+
owner: caller.owner === true,
|
|
244
|
+
name: caller.name || null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Owner-only kinds: notes, tasks, and the people reads. Tasks and people
|
|
249
|
+
// notes carry private owner context (plans, contact notes), and the people
|
|
250
|
+
// roster/record reads expose everyone the owner knows — none of it may be
|
|
251
|
+
// read or written on a non-owner (or unattributed) turn. Gated on the
|
|
252
|
+
// run-attested caller, never on payload claims.
|
|
253
|
+
if (OWNER_ONLY_KINDS.has(kind) && !callerIsOwner(payload)) {
|
|
254
|
+
return reply(res, 403, { error: "owner only" });
|
|
255
|
+
}
|
|
256
|
+
|
|
174
257
|
if (kind === "call-say") {
|
|
175
258
|
try {
|
|
176
259
|
const result = await require("./call-speech").speak({ ...payload, channelId, userId, adapter: adapterId });
|
|
@@ -215,7 +298,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
215
298
|
return reply(res, 500, { error: `voice send failed: ${e.message}` });
|
|
216
299
|
}
|
|
217
300
|
try { fs.unlinkSync(voicePath); } catch (e) {}
|
|
218
|
-
return reply(res, ok
|
|
301
|
+
return ok ? reply(res, 200, { ok }) : reply(res, 500, { ok, error: undeliveredReason(adapter, "voice note") });
|
|
219
302
|
}
|
|
220
303
|
|
|
221
304
|
if (kind === "tts") {
|
|
@@ -271,14 +354,18 @@ async function handleJson(req, res, url, kind) {
|
|
|
271
354
|
if (kind === "schedule-wakeup") {
|
|
272
355
|
const fireAt = Number(payload.fireAt);
|
|
273
356
|
if (!Number.isFinite(fireAt) || fireAt <= 0) return reply(res, 400, { error: "missing/invalid fireAt" });
|
|
357
|
+
if (fireAt - Date.now() > scheduler.MAX_WAKEUP_HORIZON_MS) {
|
|
358
|
+
return reply(res, 400, { error: "fireAt is more than 10 years out; check the date (a far reminder belongs in a yearly cron)" });
|
|
359
|
+
}
|
|
274
360
|
if (!payload.prompt) return reply(res, 400, { error: "missing prompt" });
|
|
275
361
|
const job = scheduler.addJob({
|
|
276
362
|
kind: "wakeup",
|
|
277
|
-
adapter:
|
|
278
|
-
adapterType:
|
|
279
|
-
channelId,
|
|
280
|
-
canonicalUserId:
|
|
281
|
-
userId:
|
|
363
|
+
adapter: jobCreator.adapter,
|
|
364
|
+
adapterType: jobCreator.adapterType,
|
|
365
|
+
channelId: jobCreator.channelId,
|
|
366
|
+
canonicalUserId: jobCreator.canonicalUserId,
|
|
367
|
+
userId: jobCreator.userId,
|
|
368
|
+
createdBy: jobCreatorRecord(jobCreator),
|
|
282
369
|
provider: payload.provider || null,
|
|
283
370
|
providerSettings: payload.providerSettings || {},
|
|
284
371
|
project: payload.project || null,
|
|
@@ -304,11 +391,12 @@ async function handleJson(req, res, url, kind) {
|
|
|
304
391
|
if (!payload.schedule || !payload.prompt) return reply(res, 400, { error: "missing schedule/prompt" });
|
|
305
392
|
const job = scheduler.addJob({
|
|
306
393
|
kind: "cron",
|
|
307
|
-
adapter:
|
|
308
|
-
adapterType:
|
|
309
|
-
channelId,
|
|
310
|
-
canonicalUserId:
|
|
311
|
-
userId:
|
|
394
|
+
adapter: jobCreator.adapter,
|
|
395
|
+
adapterType: jobCreator.adapterType,
|
|
396
|
+
channelId: jobCreator.channelId,
|
|
397
|
+
canonicalUserId: jobCreator.canonicalUserId,
|
|
398
|
+
userId: jobCreator.userId,
|
|
399
|
+
createdBy: jobCreatorRecord(jobCreator),
|
|
312
400
|
provider: payload.provider || null,
|
|
313
401
|
providerSettings: payload.providerSettings || {},
|
|
314
402
|
project: payload.project || null,
|
|
@@ -351,11 +439,12 @@ async function handleJson(req, res, url, kind) {
|
|
|
351
439
|
if (kind === "watch-add") {
|
|
352
440
|
if (!payload.check || !payload.prompt) return reply(res, 400, { error: "missing check/prompt" });
|
|
353
441
|
const job = scheduler.addWatch({
|
|
354
|
-
adapter:
|
|
355
|
-
adapterType:
|
|
356
|
-
channelId,
|
|
357
|
-
canonicalUserId:
|
|
358
|
-
userId:
|
|
442
|
+
adapter: jobCreator.adapter,
|
|
443
|
+
adapterType: jobCreator.adapterType,
|
|
444
|
+
channelId: jobCreator.channelId,
|
|
445
|
+
canonicalUserId: jobCreator.canonicalUserId,
|
|
446
|
+
userId: jobCreator.userId,
|
|
447
|
+
createdBy: jobCreatorRecord(jobCreator),
|
|
359
448
|
provider: payload.provider || null,
|
|
360
449
|
providerSettings: payload.providerSettings || {},
|
|
361
450
|
project: payload.project || null,
|
|
@@ -620,13 +709,13 @@ async function handleJson(req, res, url, kind) {
|
|
|
620
709
|
|
|
621
710
|
if (kind === "relay-send") {
|
|
622
711
|
if (!payload.text) return reply(res, 400, { error: "missing text" });
|
|
623
|
-
// A
|
|
624
|
-
//
|
|
712
|
+
// A non-owner requester may pass a message TO THE OWNER — that is the
|
|
713
|
+
// "tell them for me" flow — but never use the bot as a relay to third
|
|
625
714
|
// parties: sending as the bot to anyone else is impersonation surface.
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
if (
|
|
715
|
+
// Who is requesting is the run-attested caller, not the payload's claims.
|
|
716
|
+
{
|
|
717
|
+
const requester = callerOf(payload);
|
|
718
|
+
if (!requester.owner) {
|
|
630
719
|
const target =
|
|
631
720
|
(payload.targetPersonId && peopleStore.findById(payload.targetPersonId)) ||
|
|
632
721
|
(payload.targetPersonName && peopleStore.findByName(payload.targetPersonName)) ||
|
|
@@ -637,7 +726,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
637
726
|
return reply(res, 403, { error: "relay from an external contact can only go to the owner — messages to anyone else are owner-only" });
|
|
638
727
|
}
|
|
639
728
|
}
|
|
640
|
-
}
|
|
729
|
+
}
|
|
641
730
|
try {
|
|
642
731
|
const result = await relay.send({
|
|
643
732
|
text: payload.text,
|
|
@@ -656,29 +745,36 @@ async function handleJson(req, res, url, kind) {
|
|
|
656
745
|
}
|
|
657
746
|
|
|
658
747
|
if (kind === "recent-fetch") {
|
|
659
|
-
// Transcript reads are owner-side memory.
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const speaker = relationship.speakerFor(adapter.type, channelId, userId);
|
|
665
|
-
if (relationship.guardActive(speaker)) {
|
|
666
|
-
return reply(res, 403, { error: "transcript access is owner-only — an external contact cannot read other conversations" });
|
|
667
|
-
}
|
|
668
|
-
} catch (e) { /* classification failure → fall through; owner-scoped in practice */ }
|
|
748
|
+
// Transcript reads are owner-side memory. The owner may pull any channel or
|
|
749
|
+
// person; a non-owner caller is confined to THEIR OWN room and may never
|
|
750
|
+
// point this at another conversation or an owner DM. Who is calling is the
|
|
751
|
+
// run-attested principal, not the payload's forgeable channel/user claims.
|
|
752
|
+
const caller = callerOf(payload);
|
|
669
753
|
const limit = Math.max(1, Math.min(parseInt(payload.limit || 20, 10) || 20, 200));
|
|
670
754
|
try {
|
|
671
|
-
let targetAdapter
|
|
672
|
-
let targetChannel
|
|
673
|
-
if (
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
755
|
+
let targetAdapter;
|
|
756
|
+
let targetChannel;
|
|
757
|
+
if (caller.owner) {
|
|
758
|
+
targetAdapter = payload.adapter;
|
|
759
|
+
targetChannel = payload.channelId;
|
|
760
|
+
if (payload.personName || payload.personId) {
|
|
761
|
+
const person = peopleStore.findById(payload.personId || "") || peopleStore.findByName(payload.personName || "");
|
|
762
|
+
if (!person) return reply(res, 404, { error: "person not found" });
|
|
763
|
+
const handle = person.primaryChannel
|
|
764
|
+
? (person.handles || []).find((h) => h.adapter === person.primaryChannel.adapter && String(h.channelId) === String(person.primaryChannel.channelId))
|
|
765
|
+
: (person.handles || [])[0];
|
|
766
|
+
if (!handle) return reply(res, 404, { error: "person has no handles" });
|
|
767
|
+
targetAdapter = handle.adapter;
|
|
768
|
+
targetChannel = handle.channelId;
|
|
769
|
+
}
|
|
770
|
+
} else {
|
|
771
|
+
// Non-owner: ignore every target selector and pin to the caller's own
|
|
772
|
+
// room. Unattributed (no live turn) has no room → refuse.
|
|
773
|
+
if (!caller.attributed || !caller.adapterType || !caller.channelId) {
|
|
774
|
+
return reply(res, 403, { error: "transcript access outside your own room is owner-only" });
|
|
775
|
+
}
|
|
776
|
+
targetAdapter = caller.adapterType;
|
|
777
|
+
targetChannel = caller.channelId;
|
|
682
778
|
}
|
|
683
779
|
if (!targetAdapter || !targetChannel) return reply(res, 400, { error: "need adapter+channelId or person" });
|
|
684
780
|
const entries = readRecentTranscript(targetAdapter, targetChannel, limit);
|
|
@@ -701,15 +797,13 @@ async function handleJson(req, res, url, kind) {
|
|
|
701
797
|
// Safety net: this path posts an Approve/Deny for a destructive command to
|
|
702
798
|
// the REQUESTING channel. That must only ever be the owner — external
|
|
703
799
|
// mutating runs are routed to the owner via the enforcer BEFORE reaching a
|
|
704
|
-
// tool subprocess's chat-approval fallback.
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
} catch (e) { /* if classification fails, fall through — approvals stay owner-scoped by the apr: handler's isChatOwner gate */ }
|
|
800
|
+
// tool subprocess's chat-approval fallback. The requester is the
|
|
801
|
+
// run-attested caller (not the payload's forgeable claims); a non-owner
|
|
802
|
+
// (or unattributed) requester is refused — never hand an external the
|
|
803
|
+
// approve button (R8/R10).
|
|
804
|
+
if (!callerOf(payload).owner) {
|
|
805
|
+
return reply(res, 403, { error: "approval requests are owner-only; external mutating runs escalate through the guardrail" });
|
|
806
|
+
}
|
|
713
807
|
const approvals = require("./approvals");
|
|
714
808
|
// Spaces-origin runs: Spaces has no inline-button UI and its thread can be
|
|
715
809
|
// watched by non-owners, so the BINDING Approve/Deny goes to the owner's
|
|
@@ -801,10 +895,15 @@ function readRecentTranscript(adapter, channelId, limit) {
|
|
|
801
895
|
for (const line of lines) {
|
|
802
896
|
try {
|
|
803
897
|
const obj = JSON.parse(line);
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
898
|
+
// Project transcripts store the surface under obj.chat ({transport,id}),
|
|
899
|
+
// not top-level adapter/channelId — the old filter read the latter,
|
|
900
|
+
// found undefined, and so never actually scoped to the requested
|
|
901
|
+
// channel (every entry from every file leaked through). Read chat.*
|
|
902
|
+
// (with a legacy fallback) and REQUIRE a match on both.
|
|
903
|
+
const objAdapter = String(obj.chat?.transport || obj.adapter || obj.adapterType || "").toLowerCase();
|
|
904
|
+
const objChannel = String(obj.chat?.id ?? obj.channelId ?? "");
|
|
905
|
+
if (objAdapter !== adapterPrefix) continue;
|
|
906
|
+
if (objChannel !== channelStr) continue;
|
|
808
907
|
out.push({
|
|
809
908
|
at: obj.at || obj.timestamp || null,
|
|
810
909
|
role: obj.role || obj.author || null,
|
|
@@ -819,6 +918,73 @@ function readRecentTranscript(adapter, channelId, limit) {
|
|
|
819
918
|
} catch (e) { return []; }
|
|
820
919
|
}
|
|
821
920
|
|
|
921
|
+
// ── tool-gate: a guarded run's write/destructive tool run, decided HERE ─────
|
|
922
|
+
// `open-claudia tool run` used to pick the speaker from its own env
|
|
923
|
+
// (OC_CHANNEL_*) and to vet + escalate in its own process. A staff turn's CLI
|
|
924
|
+
// has neither the AI credentials (scrubbed from a non-owner run) nor the chat
|
|
925
|
+
// adapters, so its escalations never reached an owner. Now the CLI asks the
|
|
926
|
+
// bot: the caller is the verified live run (callerOf — identity fields in the
|
|
927
|
+
// payload are never read), the speaker is rebuilt from that principal, the
|
|
928
|
+
// mandate vet runs with the bot's credentials, and an escalation posts its card
|
|
929
|
+
// through the bot's own adapters. The CLI treats every answer but allow/held
|
|
930
|
+
// as blocked (fail closed).
|
|
931
|
+
async function handleToolGate(res, body) {
|
|
932
|
+
const payload = body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
933
|
+
const caller = callerOf(payload);
|
|
934
|
+
const text = (value) => (value == null ? "" : String(value));
|
|
935
|
+
const tool = text(payload.tool) || null;
|
|
936
|
+
const blocked = (reason, extra = {}) => {
|
|
937
|
+
try { audit.log("tool.gate", { decision: "blocked", tool, runId: caller.runId || null, reason: String(reason).slice(0, 160) }); } catch (_) {}
|
|
938
|
+
return reply(res, 200, { ok: true, decision: "blocked", reason, ...extra });
|
|
939
|
+
};
|
|
940
|
+
if (!caller.attributed) return blocked("not attributed to a live chat turn");
|
|
941
|
+
if (!caller.guarded) return blocked("not a guarded run — the owner's runs are gated by the tool runner itself");
|
|
942
|
+
const command = typeof payload.command === "string" ? payload.command : "";
|
|
943
|
+
const tier = text(payload.tier);
|
|
944
|
+
if (!command.trim()) return reply(res, 400, { error: "missing command" });
|
|
945
|
+
if (tier !== "write" && tier !== "destructive") return reply(res, 400, { error: "tier must be write or destructive" });
|
|
946
|
+
|
|
947
|
+
const relationship = require("./relationship");
|
|
948
|
+
let speaker = null;
|
|
949
|
+
try { speaker = relationship.speakerFor(caller.adapterType, caller.channelId, caller.userId); }
|
|
950
|
+
catch (e) { speaker = null; }
|
|
951
|
+
if (!speaker || !relationship.guardActive(speaker)) return blocked("the run's speaker could not be resolved — failing closed");
|
|
952
|
+
const name = speaker.name || caller.name || "";
|
|
953
|
+
|
|
954
|
+
const enforcer = require("./enforcer");
|
|
955
|
+
let vet;
|
|
956
|
+
try { vet = await enforcer.vetAction(speaker, { command, tier }); }
|
|
957
|
+
catch (e) { vet = { decision: "escalate", reason: "vet error — failing closed" }; }
|
|
958
|
+
if (vet && vet.decision === "allow") {
|
|
959
|
+
try { audit.log("tool.gate", { decision: "allow", tier, tool, runId: caller.runId, person: name || null }); } catch (_) {}
|
|
960
|
+
return reply(res, 200, { ok: true, decision: "allow", approvedVia: "mandate", name });
|
|
961
|
+
}
|
|
962
|
+
const providerSettings = payload.providerSettings && typeof payload.providerSettings === "object" && !Array.isArray(payload.providerSettings)
|
|
963
|
+
? payload.providerSettings : {};
|
|
964
|
+
let esc;
|
|
965
|
+
try {
|
|
966
|
+
esc = await enforcer.escalate({
|
|
967
|
+
speaker, kind: "action", command, tier, reason: vet && vet.reason,
|
|
968
|
+
// WHO the approval is for comes from the verified run; the rest only
|
|
969
|
+
// pins where an approved re-run resumes (provider/project/session).
|
|
970
|
+
canonicalUserId: caller.canonicalUserId || "",
|
|
971
|
+
userId: caller.userId || "",
|
|
972
|
+
provider: text(payload.provider),
|
|
973
|
+
providerSettings,
|
|
974
|
+
project: text(payload.project),
|
|
975
|
+
projectDir: text(payload.projectDir),
|
|
976
|
+
sessionId: text(payload.sessionId),
|
|
977
|
+
previousSessionId: text(payload.previousSessionId),
|
|
978
|
+
originRunId: caller.runId || "",
|
|
979
|
+
});
|
|
980
|
+
} catch (e) { esc = { escalated: false, reason: e.message }; }
|
|
981
|
+
if (esc && esc.escalated && esc.id) {
|
|
982
|
+
try { audit.log("tool.gate", { decision: "held", tier, tool, runId: caller.runId, person: name || null, approvalId: esc.id }); } catch (_) {}
|
|
983
|
+
return reply(res, 200, { ok: true, decision: "held", approvalId: esc.id, name });
|
|
984
|
+
}
|
|
985
|
+
return blocked(`the owner could not be reached to approve (${(esc && esc.reason) || "escalation failed"})`, { unreachable: true, name });
|
|
986
|
+
}
|
|
987
|
+
|
|
822
988
|
async function handle(req, res) {
|
|
823
989
|
try {
|
|
824
990
|
if (req.method !== "POST") return reply(res, 405, { error: "method not allowed" });
|
|
@@ -856,4 +1022,4 @@ function stop() {
|
|
|
856
1022
|
|
|
857
1023
|
process.on("exit", deleteInfo);
|
|
858
1024
|
|
|
859
|
-
module.exports = { start, stop, info, INFO_DIR, INFO_FILE };
|
|
1025
|
+
module.exports = { start, stop, info, callerOf, INFO_DIR, INFO_FILE };
|
|
@@ -31,6 +31,11 @@ function buildClaudeHookSettings(options = {}) {
|
|
|
31
31
|
// blocks provider file-mutation tools (see tool-guard.js
|
|
32
32
|
// externalFileMutationDenial). Owner/no-context turns pass through.
|
|
33
33
|
{ matcher: "^(Write|Edit|MultiEdit|NotebookEdit)$", hooks: [hook] },
|
|
34
|
+
// Guarded-speaker read gate: on external turns reads and searches that
|
|
35
|
+
// reach the bot's own state or credential stores are denied (see
|
|
36
|
+
// tool-guard.js evaluateGuardedRead). NotebookRead and LS exist only
|
|
37
|
+
// on older Claude Code builds. Owner/no-context turns pass through.
|
|
38
|
+
{ matcher: "^(Read|Grep|Glob|NotebookRead|LS)$", hooks: [hook] },
|
|
34
39
|
],
|
|
35
40
|
},
|
|
36
41
|
};
|
package/core/runner.js
CHANGED
|
@@ -1771,6 +1771,31 @@ async function deliverForegroundResult(result, runContext, state = currentState(
|
|
|
1771
1771
|
return { ok: true, chunks: chunks.length };
|
|
1772
1772
|
}
|
|
1773
1773
|
|
|
1774
|
+
// The principal a run acts for. With no chat origin (bot-internal work) that
|
|
1775
|
+
// is the owner. With a chat origin it is the speaker the relationship layer
|
|
1776
|
+
// resolves from the inbound envelope — also for a compaction stage, which
|
|
1777
|
+
// summarises that speaker's conversation; if the speaker cannot be resolved
|
|
1778
|
+
// the run is treated as a non-owner (fail closed).
|
|
1779
|
+
function runPrincipal(runContext) {
|
|
1780
|
+
const origin = runContext.origin || {};
|
|
1781
|
+
const channelId = runContext.channelId != null ? String(runContext.channelId) : origin.channelId;
|
|
1782
|
+
const base = {
|
|
1783
|
+
kind: "turn",
|
|
1784
|
+
purpose: runContext.purpose || null,
|
|
1785
|
+
canonicalUserId: runContext.canonicalUserId || origin.canonicalUserId || null,
|
|
1786
|
+
adapterId: origin.adapterId || null,
|
|
1787
|
+
adapterType: origin.transport || null,
|
|
1788
|
+
channelId: channelId || null,
|
|
1789
|
+
userId: origin.userId || null,
|
|
1790
|
+
};
|
|
1791
|
+
if (!base.adapterType || !base.channelId) return { ...base, owner: true };
|
|
1792
|
+
let speaker = null;
|
|
1793
|
+
try { speaker = require("./relationship").speakerFor(base.adapterType, base.channelId, base.userId); }
|
|
1794
|
+
catch (error) { speaker = null; }
|
|
1795
|
+
if (!speaker) return { ...base, owner: false };
|
|
1796
|
+
return { ...base, owner: speaker.isOwner === true, name: speaker.name || null, room: speaker.room || null };
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1774
1799
|
function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
1775
1800
|
const env = { ...invocation.env };
|
|
1776
1801
|
delete env.OC_CALL_SPEECH_TOKEN;
|
|
@@ -1792,6 +1817,22 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1792
1817
|
}
|
|
1793
1818
|
env.OC_PROVIDER = runContext.provider;
|
|
1794
1819
|
env.OC_RUN_ID = runContext.runId;
|
|
1820
|
+
// Run attestation: who this run acts for is decided HERE, in the bot
|
|
1821
|
+
// process, from the inbound origin — never from anything the run's own
|
|
1822
|
+
// shell can change. CLIs ask the loopback "who is this?" with
|
|
1823
|
+
// OC_RUN_ID + OC_RUN_ATTEST; the registration ends with the run (cleanup),
|
|
1824
|
+
// so a process that outlives its turn is unattributed.
|
|
1825
|
+
const principal = runPrincipal(runContext);
|
|
1826
|
+
env.OC_RUN_ATTEST = require("./speaker-attest").registerRun(runContext.runId, principal);
|
|
1827
|
+
// A non-owner's turn never sees the AI service credentials: Claude Code
|
|
1828
|
+
// strips its own auth variables from every subprocess it starts (shell,
|
|
1829
|
+
// hooks) when this is set. The agent process itself keeps them.
|
|
1830
|
+
if (principal.owner !== true) {
|
|
1831
|
+
env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = "1";
|
|
1832
|
+
// Read by the permission hook (which inherits the agent's env, not the
|
|
1833
|
+
// agent's shell's) so a guard that cannot load fails closed on this run.
|
|
1834
|
+
env.OC_GUARDED_RUN = "1";
|
|
1835
|
+
} else delete env.OC_GUARDED_RUN;
|
|
1795
1836
|
if (runContext.project?.name) env.OC_PROJECT = String(runContext.project.name);
|
|
1796
1837
|
if (runContext.project?.dir) env.OC_PROJECT_DIR = String(runContext.project.dir);
|
|
1797
1838
|
if (runContext.sessionId) env.OC_SESSION_ID = String(runContext.sessionId);
|
|
@@ -2036,6 +2077,9 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2036
2077
|
},
|
|
2037
2078
|
onStderr(chunk) { console.error("PROVIDER STDERR:", redactSensitive(chunk)); },
|
|
2038
2079
|
cleanup: async (proc, _details, runContext) => {
|
|
2080
|
+
// The run is over: its attest stops speaking for anyone, so a process
|
|
2081
|
+
// it left behind is unattributed from here on.
|
|
2082
|
+
try { require("./speaker-attest").endRun(runContext.runId); } catch (_) {}
|
|
2039
2083
|
callSpeechLeases.get(runContext.runId)?.close();
|
|
2040
2084
|
callSpeechLeases.delete(runContext.runId);
|
|
2041
2085
|
if (state.runningProcess === proc) state.runningProcess = null;
|
|
@@ -2286,8 +2330,8 @@ function queuedNotice(activeRunContext, at = Date.now()) {
|
|
|
2286
2330
|
const since = Number(activeRunContext?.admittedAt);
|
|
2287
2331
|
if (!Number.isFinite(since) || at - since < QUEUED_DETAIL_AFTER_MS) return "Queued.";
|
|
2288
2332
|
const minutes = Math.floor((at - since) / 60000);
|
|
2289
|
-
const header = String(activeRunContext.userPrompt || "").match(/^\[(Watch triggered|Watch expired|Scheduled cron|Scheduled wakeup) "([^"]*)"/);
|
|
2290
|
-
const kind = header ? { "Watch triggered": "watch", "Watch expired": "watch", "Scheduled cron": "scheduled job", "Scheduled wakeup": "wakeup" }[header[1]] : null;
|
|
2333
|
+
const header = String(activeRunContext.userPrompt || "").match(/^\[(Watch triggered|Watch expired|Watch check timing out|Scheduled cron|Scheduled wakeup) "([^"]*)"/);
|
|
2334
|
+
const kind = header ? { "Watch triggered": "watch", "Watch expired": "watch", "Watch check timing out": "watch", "Scheduled cron": "scheduled job", "Scheduled wakeup": "wakeup" }[header[1]] : null;
|
|
2291
2335
|
const label = header && header[2].length > 60 ? `${header[2].slice(0, 59).trimEnd()}…` : header?.[2];
|
|
2292
2336
|
const what = kind ? `the ${kind} "${label}"` : "an earlier message";
|
|
2293
2337
|
return `Queued behind ${what} (running ${minutes} min). /stop cancels it.`;
|
|
@@ -2422,6 +2466,7 @@ module.exports = {
|
|
|
2422
2466
|
streamPreviewAllowed,
|
|
2423
2467
|
capturedTranscriptMetadata,
|
|
2424
2468
|
decorateForegroundInvocation,
|
|
2469
|
+
runPrincipal,
|
|
2425
2470
|
createRunnerService,
|
|
2426
2471
|
createForegroundService,
|
|
2427
2472
|
createSideChatService,
|