@inetafrica/open-claudia 3.4.24 → 3.5.1
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 +55 -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 +62 -3
- package/bin/owner-gate.js +39 -0
- package/bin/pack.js +11 -1
- 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/bot-endpoint.js +194 -0
- package/core/grants.js +156 -27
- package/core/handlers.js +8 -2
- package/core/loopback.js +224 -69
- package/core/providers/claude-hook.js +5 -0
- package/core/runner.js +50 -0
- package/core/scheduler.js +100 -2
- package/core/speaker-attest.js +82 -1
- package/core/tool-guard.js +395 -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-language.js +14 -1
- package/test-provider-parity-e2e.js +8 -2
- package/test-provider-scheduler.js +14 -0
- package/test-provider-tool-hooks.js +11 -0
- package/test-tool-guard.js +12 -3
- package/test-tool-manifest.js +31 -0
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
|
},
|
package/core/loopback.js
CHANGED
|
@@ -81,7 +81,19 @@ function readBodyToFile(req, dest) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
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
|
+
]);
|
|
84
95
|
const JSON_KINDS = new Set([
|
|
96
|
+
"whoami",
|
|
85
97
|
"schedule-wakeup", "cron-add", "cron-remove", "job-list",
|
|
86
98
|
"watch-add", "watch-list", "watch-remove", "watch-toggle",
|
|
87
99
|
"task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
|
|
@@ -91,22 +103,46 @@ const JSON_KINDS = new Set([
|
|
|
91
103
|
"relay-send", "recent-fetch", "audit-tail",
|
|
92
104
|
"auth-list", "auth-revoke",
|
|
93
105
|
"approval-request",
|
|
106
|
+
"tool-gate",
|
|
94
107
|
"say", "call-say", "call", "tts", "transcribe",
|
|
95
108
|
]);
|
|
96
109
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
104
144
|
function callerIsOwner(payload) {
|
|
105
|
-
|
|
106
|
-
if (!caller) return false;
|
|
107
|
-
if (!speakerAttest.verify(caller, payload?.speakerAttest)) return false;
|
|
108
|
-
const person = peopleStore.findByCanonicalUserId(caller);
|
|
109
|
-
return !!(person && person.isOwner);
|
|
145
|
+
return callerOf(payload).owner === true;
|
|
110
146
|
}
|
|
111
147
|
|
|
112
148
|
function readBodyAsString(req, max = 64 * 1024) {
|
|
@@ -172,6 +208,11 @@ async function handleJson(req, res, url, kind) {
|
|
|
172
208
|
try { payload = JSON.parse(body); }
|
|
173
209
|
catch (e) { return reply(res, 400, { error: "invalid JSON body" }); }
|
|
174
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);
|
|
175
216
|
const channelId = String(payload.channelId || url.searchParams.get("channelId") || "");
|
|
176
217
|
const userId = String(payload.userId || url.searchParams.get("userId") || "");
|
|
177
218
|
const adapterId = String(payload.adapter || url.searchParams.get("adapter") || "");
|
|
@@ -179,6 +220,40 @@ async function handleJson(req, res, url, kind) {
|
|
|
179
220
|
const adapter = registry.findAdapter(adapterId);
|
|
180
221
|
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
181
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
|
+
|
|
182
257
|
if (kind === "call-say") {
|
|
183
258
|
try {
|
|
184
259
|
const result = await require("./call-speech").speak({ ...payload, channelId, userId, adapter: adapterId });
|
|
@@ -285,11 +360,12 @@ async function handleJson(req, res, url, kind) {
|
|
|
285
360
|
if (!payload.prompt) return reply(res, 400, { error: "missing prompt" });
|
|
286
361
|
const job = scheduler.addJob({
|
|
287
362
|
kind: "wakeup",
|
|
288
|
-
adapter:
|
|
289
|
-
adapterType:
|
|
290
|
-
channelId,
|
|
291
|
-
canonicalUserId:
|
|
292
|
-
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),
|
|
293
369
|
provider: payload.provider || null,
|
|
294
370
|
providerSettings: payload.providerSettings || {},
|
|
295
371
|
project: payload.project || null,
|
|
@@ -315,11 +391,12 @@ async function handleJson(req, res, url, kind) {
|
|
|
315
391
|
if (!payload.schedule || !payload.prompt) return reply(res, 400, { error: "missing schedule/prompt" });
|
|
316
392
|
const job = scheduler.addJob({
|
|
317
393
|
kind: "cron",
|
|
318
|
-
adapter:
|
|
319
|
-
adapterType:
|
|
320
|
-
channelId,
|
|
321
|
-
canonicalUserId:
|
|
322
|
-
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),
|
|
323
400
|
provider: payload.provider || null,
|
|
324
401
|
providerSettings: payload.providerSettings || {},
|
|
325
402
|
project: payload.project || null,
|
|
@@ -362,11 +439,12 @@ async function handleJson(req, res, url, kind) {
|
|
|
362
439
|
if (kind === "watch-add") {
|
|
363
440
|
if (!payload.check || !payload.prompt) return reply(res, 400, { error: "missing check/prompt" });
|
|
364
441
|
const job = scheduler.addWatch({
|
|
365
|
-
adapter:
|
|
366
|
-
adapterType:
|
|
367
|
-
channelId,
|
|
368
|
-
canonicalUserId:
|
|
369
|
-
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),
|
|
370
448
|
provider: payload.provider || null,
|
|
371
449
|
providerSettings: payload.providerSettings || {},
|
|
372
450
|
project: payload.project || null,
|
|
@@ -631,13 +709,13 @@ async function handleJson(req, res, url, kind) {
|
|
|
631
709
|
|
|
632
710
|
if (kind === "relay-send") {
|
|
633
711
|
if (!payload.text) return reply(res, 400, { error: "missing text" });
|
|
634
|
-
// A
|
|
635
|
-
//
|
|
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
|
|
636
714
|
// parties: sending as the bot to anyone else is impersonation surface.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const
|
|
640
|
-
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) {
|
|
641
719
|
const target =
|
|
642
720
|
(payload.targetPersonId && peopleStore.findById(payload.targetPersonId)) ||
|
|
643
721
|
(payload.targetPersonName && peopleStore.findByName(payload.targetPersonName)) ||
|
|
@@ -648,7 +726,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
648
726
|
return reply(res, 403, { error: "relay from an external contact can only go to the owner — messages to anyone else are owner-only" });
|
|
649
727
|
}
|
|
650
728
|
}
|
|
651
|
-
}
|
|
729
|
+
}
|
|
652
730
|
try {
|
|
653
731
|
const result = await relay.send({
|
|
654
732
|
text: payload.text,
|
|
@@ -667,29 +745,36 @@ async function handleJson(req, res, url, kind) {
|
|
|
667
745
|
}
|
|
668
746
|
|
|
669
747
|
if (kind === "recent-fetch") {
|
|
670
|
-
// Transcript reads are owner-side memory.
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const speaker = relationship.speakerFor(adapter.type, channelId, userId);
|
|
676
|
-
if (relationship.guardActive(speaker)) {
|
|
677
|
-
return reply(res, 403, { error: "transcript access is owner-only — an external contact cannot read other conversations" });
|
|
678
|
-
}
|
|
679
|
-
} 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);
|
|
680
753
|
const limit = Math.max(1, Math.min(parseInt(payload.limit || 20, 10) || 20, 200));
|
|
681
754
|
try {
|
|
682
|
-
let targetAdapter
|
|
683
|
-
let targetChannel
|
|
684
|
-
if (
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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;
|
|
693
778
|
}
|
|
694
779
|
if (!targetAdapter || !targetChannel) return reply(res, 400, { error: "need adapter+channelId or person" });
|
|
695
780
|
const entries = readRecentTranscript(targetAdapter, targetChannel, limit);
|
|
@@ -712,15 +797,13 @@ async function handleJson(req, res, url, kind) {
|
|
|
712
797
|
// Safety net: this path posts an Approve/Deny for a destructive command to
|
|
713
798
|
// the REQUESTING channel. That must only ever be the owner — external
|
|
714
799
|
// mutating runs are routed to the owner via the enforcer BEFORE reaching a
|
|
715
|
-
// tool subprocess's chat-approval fallback.
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
723
|
-
} 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
|
+
}
|
|
724
807
|
const approvals = require("./approvals");
|
|
725
808
|
// Spaces-origin runs: Spaces has no inline-button UI and its thread can be
|
|
726
809
|
// watched by non-owners, so the BINDING Approve/Deny goes to the owner's
|
|
@@ -812,10 +895,15 @@ function readRecentTranscript(adapter, channelId, limit) {
|
|
|
812
895
|
for (const line of lines) {
|
|
813
896
|
try {
|
|
814
897
|
const obj = JSON.parse(line);
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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;
|
|
819
907
|
out.push({
|
|
820
908
|
at: obj.at || obj.timestamp || null,
|
|
821
909
|
role: obj.role || obj.author || null,
|
|
@@ -830,6 +918,73 @@ function readRecentTranscript(adapter, channelId, limit) {
|
|
|
830
918
|
} catch (e) { return []; }
|
|
831
919
|
}
|
|
832
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
|
+
|
|
833
988
|
async function handle(req, res) {
|
|
834
989
|
try {
|
|
835
990
|
if (req.method !== "POST") return reply(res, 405, { error: "method not allowed" });
|
|
@@ -867,4 +1022,4 @@ function stop() {
|
|
|
867
1022
|
|
|
868
1023
|
process.on("exit", deleteInfo);
|
|
869
1024
|
|
|
870
|
-
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,36 @@ 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 at all (bot-internal work)
|
|
1775
|
+
// that is the owner. With a chat origin it is the speaker the relationship
|
|
1776
|
+
// layer resolves from the inbound envelope — also for a compaction stage, which
|
|
1777
|
+
// summarises that speaker's conversation; if the speaker cannot be resolved,
|
|
1778
|
+
// or the origin names someone but not the transport and room it came from, the
|
|
1779
|
+
// run is treated as a non-owner (fail closed).
|
|
1780
|
+
const ORIGIN_FIELDS = Object.freeze(["adapterId", "transport", "channelId", "userId", "canonicalUserId"]);
|
|
1781
|
+
function runPrincipal(runContext) {
|
|
1782
|
+
const origin = runContext.origin || {};
|
|
1783
|
+
const channelId = runContext.channelId != null ? String(runContext.channelId) : origin.channelId;
|
|
1784
|
+
const base = {
|
|
1785
|
+
kind: "turn",
|
|
1786
|
+
purpose: runContext.purpose || null,
|
|
1787
|
+
canonicalUserId: runContext.canonicalUserId || origin.canonicalUserId || null,
|
|
1788
|
+
adapterId: origin.adapterId || null,
|
|
1789
|
+
adapterType: origin.transport || null,
|
|
1790
|
+
channelId: channelId || null,
|
|
1791
|
+
userId: origin.userId || null,
|
|
1792
|
+
};
|
|
1793
|
+
if (!base.adapterType || !base.channelId) {
|
|
1794
|
+
const partial = ORIGIN_FIELDS.some((key) => origin[key] != null && origin[key] !== "");
|
|
1795
|
+
return { ...base, owner: !partial };
|
|
1796
|
+
}
|
|
1797
|
+
let speaker = null;
|
|
1798
|
+
try { speaker = require("./relationship").speakerFor(base.adapterType, base.channelId, base.userId); }
|
|
1799
|
+
catch (error) { speaker = null; }
|
|
1800
|
+
if (!speaker) return { ...base, owner: false };
|
|
1801
|
+
return { ...base, owner: speaker.isOwner === true, name: speaker.name || null, room: speaker.room || null };
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1774
1804
|
function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
1775
1805
|
const env = { ...invocation.env };
|
|
1776
1806
|
delete env.OC_CALL_SPEECH_TOKEN;
|
|
@@ -1792,6 +1822,22 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1792
1822
|
}
|
|
1793
1823
|
env.OC_PROVIDER = runContext.provider;
|
|
1794
1824
|
env.OC_RUN_ID = runContext.runId;
|
|
1825
|
+
// Run attestation: who this run acts for is decided HERE, in the bot
|
|
1826
|
+
// process, from the inbound origin — never from anything the run's own
|
|
1827
|
+
// shell can change. CLIs ask the loopback "who is this?" with
|
|
1828
|
+
// OC_RUN_ID + OC_RUN_ATTEST; the registration ends with the run (cleanup),
|
|
1829
|
+
// so a process that outlives its turn is unattributed.
|
|
1830
|
+
const principal = runPrincipal(runContext);
|
|
1831
|
+
env.OC_RUN_ATTEST = require("./speaker-attest").registerRun(runContext.runId, principal);
|
|
1832
|
+
// A non-owner's turn never sees the AI service credentials: Claude Code
|
|
1833
|
+
// strips its own auth variables from every subprocess it starts (shell,
|
|
1834
|
+
// hooks) when this is set. The agent process itself keeps them.
|
|
1835
|
+
if (principal.owner !== true) {
|
|
1836
|
+
env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = "1";
|
|
1837
|
+
// Read by the permission hook (which inherits the agent's env, not the
|
|
1838
|
+
// agent's shell's) so a guard that cannot load fails closed on this run.
|
|
1839
|
+
env.OC_GUARDED_RUN = "1";
|
|
1840
|
+
} else delete env.OC_GUARDED_RUN;
|
|
1795
1841
|
if (runContext.project?.name) env.OC_PROJECT = String(runContext.project.name);
|
|
1796
1842
|
if (runContext.project?.dir) env.OC_PROJECT_DIR = String(runContext.project.dir);
|
|
1797
1843
|
if (runContext.sessionId) env.OC_SESSION_ID = String(runContext.sessionId);
|
|
@@ -2036,6 +2082,9 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2036
2082
|
},
|
|
2037
2083
|
onStderr(chunk) { console.error("PROVIDER STDERR:", redactSensitive(chunk)); },
|
|
2038
2084
|
cleanup: async (proc, _details, runContext) => {
|
|
2085
|
+
// The run is over: its attest stops speaking for anyone, so a process
|
|
2086
|
+
// it left behind is unattributed from here on.
|
|
2087
|
+
try { require("./speaker-attest").endRun(runContext.runId); } catch (_) {}
|
|
2039
2088
|
callSpeechLeases.get(runContext.runId)?.close();
|
|
2040
2089
|
callSpeechLeases.delete(runContext.runId);
|
|
2041
2090
|
if (state.runningProcess === proc) state.runningProcess = null;
|
|
@@ -2422,6 +2471,7 @@ module.exports = {
|
|
|
2422
2471
|
streamPreviewAllowed,
|
|
2423
2472
|
capturedTranscriptMetadata,
|
|
2424
2473
|
decorateForegroundInvocation,
|
|
2474
|
+
runPrincipal,
|
|
2425
2475
|
createRunnerService,
|
|
2426
2476
|
createForegroundService,
|
|
2427
2477
|
createSideChatService,
|