@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/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
- // Owner gate for identity-sensitive kinds. The claimed canonicalUserId comes
90
- // from the CLI's env, which the agent's shell can rewrite — so the claim only
91
- // counts when it carries the runner-minted attestation (an HMAC over the id
92
- // with a per-boot, memory-only secret; see core/speaker-attest). No attest,
93
- // a wrong attest, or an attest for a different id all fail closed: forging
94
- // `OC_CANONICAL_USER_ID=<owner-id>` from a guarded external turn no longer
95
- // reaches the people/auth/intros mutations the relationship guard depends on.
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
- const caller = payload?.canonicalUserId;
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 ? 200 : 500, { 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 ? 200 : 500, { 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: adapterId,
278
- adapterType: adapter.type,
279
- channelId,
280
- canonicalUserId: payload.canonicalUserId || null,
281
- userId: payload.userId || null,
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: adapterId,
308
- adapterType: adapter.type,
309
- channelId,
310
- canonicalUserId: payload.canonicalUserId || null,
311
- userId: payload.userId || null,
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: adapterId,
355
- adapterType: adapter.type,
356
- channelId,
357
- canonicalUserId: payload.canonicalUserId || null,
358
- userId: payload.userId || null,
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 guarded (external) requester may pass a message TO THE OWNER — that is
624
- // the "tell them for me" flow — but never use the bot as a relay to third
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
- try {
627
- const relationship = require("./relationship");
628
- const speaker = relationship.speakerFor(adapter.type, channelId, userId);
629
- if (relationship.guardActive(speaker)) {
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
- } catch (e) { /* classification failure → fall through; owner-scoped in practice */ }
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. A guarded (external, non-owner)
660
- // requester must not pull other channels' conversations through the CLI —
661
- // same default-deny as file/voice egress, same fail direction.
662
- try {
663
- const relationship = require("./relationship");
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 = payload.adapter;
672
- let targetChannel = payload.channelId;
673
- if (payload.personName || payload.personId) {
674
- const person = peopleStore.findById(payload.personId || "") || peopleStore.findByName(payload.personName || "");
675
- if (!person) return reply(res, 404, { error: "person not found" });
676
- const handle = person.primaryChannel
677
- ? (person.handles || []).find((h) => h.adapter === person.primaryChannel.adapter && String(h.channelId) === String(person.primaryChannel.channelId))
678
- : (person.handles || [])[0];
679
- if (!handle) return reply(res, 404, { error: "person has no handles" });
680
- targetAdapter = handle.adapter;
681
- targetChannel = handle.channelId;
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. If the requester resolves to a
705
- // non-owner, refuse: never hand an external the approve button (R8/R10).
706
- try {
707
- const relationship = require("./relationship");
708
- const speaker = relationship.speakerFor(adapter.type, channelId, userId);
709
- if (relationship.guardActive(speaker)) {
710
- return reply(res, 403, { error: "approval requests are owner-only; external mutating runs escalate through the guardrail" });
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
- const objAdapter = (obj.adapter || obj.adapterType || "").toLowerCase();
805
- const objChannel = String(obj.channelId || "");
806
- if (objAdapter && objAdapter !== adapterPrefix) continue;
807
- if (objChannel && objChannel !== channelStr) continue;
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,