@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.
- package/CHANGELOG.md +40 -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/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 +224 -69
- package/core/providers/claude-hook.js +5 -0
- package/core/runner.js +45 -0
- package/core/scheduler.js +110 -2
- 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-language.js +9 -1
- 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/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,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;
|
|
@@ -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,
|
package/core/scheduler.js
CHANGED
|
@@ -73,6 +73,84 @@ function uniqueId(prefix = "occ") {
|
|
|
73
73
|
: `${prefix}-${Date.now()}-${crypto.randomBytes(6).toString("hex")}`;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// ── watch-check environment ────────────────────────────────────────────────
|
|
77
|
+
// A watch's `check` runs a shell command. It used to inherit `env:
|
|
78
|
+
// process.env` — every secret the bot process holds (provider credentials,
|
|
79
|
+
// bot tokens, the loopback token), so a staff member's check could read them
|
|
80
|
+
// all. Build the env explicitly instead: keep the harmless shell essentials
|
|
81
|
+
// and the Open Claudia config dir (so `open-claudia ...` resolves its config),
|
|
82
|
+
// then drop anything that looks like a credential (a broad denylist pattern)
|
|
83
|
+
// plus the known-secret names the provider env-isolation layer already
|
|
84
|
+
// strips. A short-lived, creator-scoped run is stamped on top (below) so the
|
|
85
|
+
// check's reusable tools still attribute to whoever created the watch.
|
|
86
|
+
const WATCH_CHECK_ENV_KEEP = new Set([
|
|
87
|
+
"PATH", "HOME", "USER", "LOGNAME", "SHELL",
|
|
88
|
+
"LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ",
|
|
89
|
+
"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR",
|
|
90
|
+
"TMPDIR", "TMP", "TEMP",
|
|
91
|
+
"OPEN_CLAUDIA_CONFIG_DIR",
|
|
92
|
+
"SystemRoot", "ComSpec", "PATHEXT", "WINDIR",
|
|
93
|
+
]);
|
|
94
|
+
const WATCH_CHECK_SECRET_PATTERN = /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|APIKEY|OAUTH|CREDENTIAL|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|SESSION[_-]?KEY|BEARER)/i;
|
|
95
|
+
let KNOWN_SECRET_ENV_KEYS = null;
|
|
96
|
+
function knownSecretEnvKeys() {
|
|
97
|
+
if (KNOWN_SECRET_ENV_KEYS) return KNOWN_SECRET_ENV_KEYS;
|
|
98
|
+
const set = new Set();
|
|
99
|
+
try { for (const key of require("./providers/env").ALWAYS_STRIPPED_KEYS) set.add(key); } catch (_) {}
|
|
100
|
+
KNOWN_SECRET_ENV_KEYS = set;
|
|
101
|
+
return set;
|
|
102
|
+
}
|
|
103
|
+
function isSecretCheckEnvKey(key) {
|
|
104
|
+
if (WATCH_CHECK_ENV_KEEP.has(key)) return false;
|
|
105
|
+
if (knownSecretEnvKeys().has(key)) return true;
|
|
106
|
+
return WATCH_CHECK_SECRET_PATTERN.test(key);
|
|
107
|
+
}
|
|
108
|
+
function buildWatchCheckEnv(source, stamp = {}) {
|
|
109
|
+
const env = {};
|
|
110
|
+
for (const [key, value] of Object.entries(source || {})) {
|
|
111
|
+
if (value == null) continue;
|
|
112
|
+
if (isSecretCheckEnvKey(key)) continue;
|
|
113
|
+
env[key] = String(value);
|
|
114
|
+
}
|
|
115
|
+
for (const [key, value] of Object.entries(stamp)) {
|
|
116
|
+
if (value == null || value === "") continue;
|
|
117
|
+
env[key] = String(value);
|
|
118
|
+
}
|
|
119
|
+
return env;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The principal a watch-check's short-lived run acts for: the job's creator,
|
|
123
|
+
// resolved from its stored channel/user the same way runPrincipal resolves a
|
|
124
|
+
// turn's speaker. Owner → the check attributes as the owner (unchanged); a
|
|
125
|
+
// staff creator → the check runs at their level. Fail closed to non-owner.
|
|
126
|
+
function watchCheckPrincipal(job) {
|
|
127
|
+
const adapterType = job && job.adapterType ? String(job.adapterType) : null;
|
|
128
|
+
const channelId = job && job.channelId != null ? String(job.channelId) : null;
|
|
129
|
+
const userId = job && job.userId != null ? String(job.userId) : null;
|
|
130
|
+
let owner = false;
|
|
131
|
+
let name = null;
|
|
132
|
+
let room = null;
|
|
133
|
+
if (adapterType && channelId) {
|
|
134
|
+
try {
|
|
135
|
+
const sp = require("./relationship").speakerFor(adapterType, channelId, userId);
|
|
136
|
+
owner = !!(sp && sp.isOwner === true);
|
|
137
|
+
name = (sp && sp.name) || null;
|
|
138
|
+
room = (sp && sp.room) || null;
|
|
139
|
+
} catch (_) { owner = false; }
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
kind: "watch-check",
|
|
143
|
+
owner,
|
|
144
|
+
canonicalUserId: job && job.canonicalUserId ? String(job.canonicalUserId) : null,
|
|
145
|
+
name,
|
|
146
|
+
adapterId: job && job.adapter ? String(job.adapter) : null,
|
|
147
|
+
adapterType,
|
|
148
|
+
channelId,
|
|
149
|
+
userId,
|
|
150
|
+
room,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
76
154
|
function parseFallbacks(value) {
|
|
77
155
|
if (Array.isArray(value)) return [...new Set(value.map((item) => String(item).trim().toLowerCase()).filter(Boolean))];
|
|
78
156
|
return [...new Set(String(value || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean))];
|
|
@@ -155,23 +233,49 @@ function createSchedulerService(dependencies = {}) {
|
|
|
155
233
|
const timers = new Map();
|
|
156
234
|
const checksInFlight = new Set();
|
|
157
235
|
const firesInFlight = new Set();
|
|
236
|
+
// Jobs already on disk before the creator-binding fix carry no `createdBy`.
|
|
237
|
+
// We keep running them AS THEY ARE (their stored channel/user is what the
|
|
238
|
+
// fire path resolves the speaker from — the owner's own watches and wakeups
|
|
239
|
+
// must not silently break), but log the first time each fires so an audit
|
|
240
|
+
// can spot a pre-fix job that outranks a non-owner. Logged once per id/boot.
|
|
241
|
+
const loggedLegacyCreators = new Set();
|
|
242
|
+
function noteLegacyCreatorOnce(job) {
|
|
243
|
+
if (!job || job.createdBy || loggedLegacyCreators.has(job.id)) return;
|
|
244
|
+
loggedLegacyCreators.add(job.id);
|
|
245
|
+
try {
|
|
246
|
+
console.warn(`[scheduler] job ${job.id} (${job.kind}${job.check ? "/watch" : ""}) predates creator binding — running with its stored identity ${job.canonicalUserId || "?"} in ${job.adapterType || "?"}:${job.channelId || "?"}`);
|
|
247
|
+
} catch (_) { /* logging is best-effort */ }
|
|
248
|
+
}
|
|
158
249
|
// Poll a watch's `check`: exit 0 → the watched condition is met, wake the
|
|
159
250
|
// agent; non-zero → still idle, skip silently. Runs in the job's project
|
|
160
|
-
// dir with
|
|
251
|
+
// dir with a HARDENED env — the bot's secrets are dropped (buildWatchCheckEnv)
|
|
252
|
+
// and a short-lived run is registered for the job's creator so reusable
|
|
253
|
+
// tools attribute correctly — output discarded (exit code is the contract),
|
|
161
254
|
// hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
|
|
162
255
|
const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
|
|
163
256
|
const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
|
|
257
|
+
const attest = require("./speaker-attest");
|
|
258
|
+
const checkRunId = uniqueId("watch-check");
|
|
259
|
+
let runAttest = "";
|
|
260
|
+
try { runAttest = attest.registerRun(checkRunId, watchCheckPrincipal(job)); } catch (_) { runAttest = ""; }
|
|
261
|
+
let lb = null;
|
|
262
|
+
try { lb = require("./loopback").info(); } catch (_) { lb = null; }
|
|
263
|
+
const stamp = { OC_RUN_ID: checkRunId, OC_RUN_ATTEST: runAttest };
|
|
264
|
+
if (lb && lb.url) { stamp.OC_SEND_URL = lb.url; stamp.OC_SEND_TOKEN = lb.token; }
|
|
265
|
+
const env = buildWatchCheckEnv(process.env, stamp);
|
|
164
266
|
let settled = false;
|
|
165
267
|
const finish = (code, timedOut) => {
|
|
166
268
|
if (settled) return;
|
|
167
269
|
settled = true;
|
|
168
270
|
try { clearTimer(timer); } catch (_) {}
|
|
271
|
+
try { attest.endRun(checkRunId); } catch (_) {}
|
|
169
272
|
resolve({ code, timedOut: !!timedOut });
|
|
170
273
|
};
|
|
171
274
|
let child;
|
|
172
275
|
try {
|
|
173
|
-
child = spawn("sh", ["-c", String(command)], { cwd, env
|
|
276
|
+
child = spawn("sh", ["-c", String(command)], { cwd, env, stdio: "ignore" });
|
|
174
277
|
} catch (error) {
|
|
278
|
+
try { attest.endRun(checkRunId); } catch (_) {}
|
|
175
279
|
return resolve({ code: 127, timedOut: false });
|
|
176
280
|
}
|
|
177
281
|
const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
|
|
@@ -624,6 +728,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
624
728
|
};
|
|
625
729
|
|
|
626
730
|
return runInChat(chat, async () => {
|
|
731
|
+
noteLegacyCreatorOnce(job);
|
|
627
732
|
const state = stateForUser(canonicalUserId);
|
|
628
733
|
if (state.runningProcess || state.preparingRun || state.isCompacting) {
|
|
629
734
|
try { return markBusy(job); }
|
|
@@ -974,6 +1079,9 @@ module.exports = {
|
|
|
974
1079
|
createSchedulerService,
|
|
975
1080
|
parseFallbacks,
|
|
976
1081
|
terminalRunSucceeded,
|
|
1082
|
+
buildWatchCheckEnv,
|
|
1083
|
+
isSecretCheckEnvKey,
|
|
1084
|
+
watchCheckPrincipal,
|
|
977
1085
|
setAdapters,
|
|
978
1086
|
initScheduler,
|
|
979
1087
|
scheduleJob: service.scheduleJob,
|